lunes, 19 de noviembre de 2012

Ax's white space aversion

I can think of a few places where I would like to output some nice white space to the end user, cushioning their existence from the vagaries of life...  But Ax 2009 isn't letting me:

  • In table fields. I can confirm that trailing spaces are removed, which is a shame when you want to store pre generated rows of data that are to be exported to plain text files, in a host system that will then parse fixed length fields, padded with either zeros or white space.
  • In the label text. Try as I might, that important white space from the right is cruelly taken away from me. My formatting and design of Ax forms is taken to new heights of frustration and anger. Well, perhaps I exaggerate a little after all we should be changing the margin fields and not manually adding spaces to the labels after the full colon.
  • Directly in the editor, the white space is removed from the end of the lines.  It doesn't interfere normally, but when using the @ operator for strings it can upset your precious formatting.

Use the \n in strings to create a new line... And use labels.

I've discovered this aversion to trailing white space from Microsoft years ago ever since my Hotmail account's password used to have a trailing white space in it... And now it doesn't any more. Same password but minus the w/s. Is white space actually dangerous?

miércoles, 14 de noviembre de 2012

Problem of the slow loading customer invoice journal

The title makes this blog post sound like a Scooby Doo caper.

With the GLP-EE (Eastern Europe) patch installed in an Ax 2009 SP 1 instance we found that the customer invoice journal was loading slower and slower.  The form was nothing short of painful.  It seemed to be okay when working with invoices from Spain, Morocco, Italy...

After restarting both Ax and MSSQLServer instances it was time to roll my sleeves up and investigate with the SQL Server Profiler.  While you and I may still be an amateur with the tool a quick perusal of the output identifies the problem query.  After the restart of Ax I relaunched the profiler and immediately opened the invoice journal.  Finding the problematic sql can be difficult due to the use of sp_cursorfetch, hiding from us the original query.  The sp_cursorprepexec is eventually identified, and we can see the sql used in the cursor that is being reused all the time.

Next up was shouting at the silicon gods and then creating a new table index (CustInvoiceTrans) within Ax, to include a new field introduced from the aforementioned patch - RefReturnInvoiceTrans_W.
Maybe should have just added the field to the InvoiceIdx, huh?
With our new index we can compare query times and the difference is huge.


All that I can say is that Microsoft owes me a beer.

miércoles, 31 de octubre de 2012

Bug: Version Control


Not using version control on your code is like playing Russian Roulette over the whole lifetime of your project.  You might not 'lose' anything at first, but later on you may regret the omission.

I had a strange bug upon opening the Ax Client related the to the inbuilt version control system, and didn't know where it came from
Translation: Error
A quick look in the logs, indicated the cause of the problem...

   Object Server 01:  
   [Microsoft][SQL Native Client][SQL Server]El nombre de columna 'MODIFIEDBY' no es válido. 
   INSERT INTO SYSVERSIONCONTROLMORPHXITE2541 (ITEMPATH,MODIFIEDBY,CREATEDBY,RECVERSION,RECID) OUTPUT INSERTED.CREATEDDATETIME VALUES (?,?,?,?,?) 

What fool did this then?...  Me!
For some reason we had added the ModifiedBy, ModifiedDatetime, etc, etc fields to the entity!  I may have done this accidentally when I added the fields to entities such as LedgerTable, TaxTable, TaxData...  Some of them had been added to the version control, and some of them were not.

miércoles, 24 de octubre de 2012

Adjusting Sales/Purchase Tax

When importing invoices from an external system you find penny differences between how tax is calculated between these two applications.  Integrating thousands of invoices can surmount to a whole free beer a day appearing on your ledger books, much to the disdain of our client's accountant.  A decision was therefore taken to send me to New Zealand for the week to fix the problem and force Ax to accept some different tax rounding rules.

A penny difference in how Ax and an external system calculates taxes.
When registering a sales invoice it is possible to manually adjust the tax amount, per tax code.
This is what we want to do, via X++.
Fortunately a blogger called KiwiAxGuy has already achieved half of the work for us, adjusting tax on journals and free text invoices.  No need for those plane tickets after all!

static void XXX_AdjustSalesTaxesJob(Args _args)
{    
    //Manually adjust sales taxes
    TaxRegulation       taxRegulation;
    SalesTable          salesTable;
    SalesTotals         salesTotals;
    ;
    salesTable         = SalesTable::find('12A0012');

    if (salesTable)
    {
        salesTotals = SalesTotals::construct(salesTable, SalesUpdate::All);
        salesTotals.calc();

        // Launch the form to manually adjust the taxes (TaxTmpWorkTrans)
        // --------------------------------------------------------------
        //Tax::showTaxes(salesTotals.tax(), salesTable);

        // Or do it by X++
        // ---------------
        ttsBegin;
        // EDIT: I've just realised that this method has been modified in the GLP-EE patch
        // taxRegulation = TaxRegulation::newTaxRegulation(salesTotals.tax(), null);
        taxRegulation = TaxRegulation::newTaxRegulation(salesTotals.tax(), null, salesTable, null, null);
        taxRegulation.allocateAmount(498.54);
        taxRegulation.saveTaxRegulation();
        ttsCommit;
    }
}


The same can be done for purchase invoices.  In my example once more we have a penny difference in the tax calculation, it should be 13.60.
Purchase order, with 14.60 PLN of taxes we need to apply

static void XXX_AdjustPurchTaxesJob(Args _args)
{
    TaxRegulation       taxRegulation;
    PurchTable          purchTable;
    PurchTotals         purchTotals;
    ;
    purchTable         = PurchTable::find('129/2012');

    if (purchTable)
    {
        purchTotals = PurchTotals::newPurchTable(purchTable, PurchUpdate::All);
        purchTotals.calc();

        // Launch the Form to manually adjust the taxes (TaxTmpWorkTrans)
        // --------------------------------------------------------------
        //Tax::showTaxes(purchTotals.tax(), purchTable);

        // Or do it by X++
        // ---------------
        ttsBegin;
        taxRegulation = TaxRegulation::newTaxRegulation(purchTotals.tax(), null, null, purchTable, null);
        if (taxRegulation.taxLinesExist())
        {
            taxRegulation.allocateAmount(13.60);
            taxRegulation.saveTaxRegulation();
        }
        ttsCommit;
    }
}

HOWEVER.  What happens when we have two tax codes in the same invoice?

Two tax codes, one value to change.
For me it didn't work.  The TaxRegulation class allocated the change in amount to the second tax line, but didn't save the changes in the underlying temporary table :(  Check out the knowledge base article KB2028633 as I'm confident that it's the same issue (more Ax 2009, GLP-EE pains).
You people with the AX 2009 Eastern European patch have been warned.

Our changes are all recorded in the TaxWorkRegulation entity, should you need to check your changes across invoices.
TaxWorkRegulation - The entity that stores the adjusted taxes we have applied... Where is my second line?
BUT, "After posting is successful, all records in this table related to the transaction are deleted." and so within TaxTrans we have the columns SourceTaxAmountCur (calculated sales tax amount shown in the current currency) and SourceRegulateAmountCur (amount that the sales tax is being adjusted to) to compare.

Edit:  Found another possible bug, or it's my lack of knowledge.  Registering changes to taxes for the Purch* entities, I could only do it by changing the HeadingTableId and HeadingRecId values to that of PurchParmTable (343), and not PurchTable (345).  This is probably something to do with rearranging the purchase lines.

Remember people:  Taxes are hard.  Let's go tax-free shopping!

sábado, 22 de septiembre de 2012

When buf2buf is not enough

When the global buf2buf is not enough! Copying two heterogeneous table buffers, but with practically the same field names.
Edit: Peter Chan beat me to it! The only advantage to using my function is an extra semi colon :)
Neither of our methods check to see if the field type is the same...
///Copy two different table buffers but with the same field names.
///May be better to use a Map...
static void bufFields2BufFields(
    Common  _from,
    Common  _to
    )
{
    DictTable   dictTableFr = new DictTable(_from.TableId);
    FieldId     fieldIdFr;
    DictTable   dictTableTo = new DictTable(_to.TableId);
    FieldId     fieldIdTo;
    FieldName   fieldName;
    ;
    fieldIdFr   = dictTableFr.fieldNext(0);
    fieldName   = dictTableFr.fieldName(fieldIdFr);
    fieldIdTo   = dictTableTo.fieldName2Id(fieldName);

    while (fieldIdFr && ! isSysId(fieldIdFr))
    {
        if (fieldIdTo)
        {
            _to.(fieldIdTo)     = _from.(fieldIdFr);
        }
        
        fieldIdFr           = dictTableFr.fieldNext(fieldIdFr);
        fieldName           = dictTableFr.fieldName(fieldIdFr);
        fieldIdTo           = dictTableTo.fieldName2Id(fieldName);
    }
}

miércoles, 5 de septiembre de 2012

AX 2012 Number Sequence, X++

It's feels like having to learn all of this stuff over again.  Here is a brief overview, and there is a white paper available as well, for AX 2012.

static void tutorial_NumberSequenceJob(Args _args)
{
    // AX 2012 Number Sequence, X++
    // Only for sequences marked as continuous
    // ---------------------------------------
    NumberSequenceReference nsRef;    
    NumberSeqScope    scope;
    ProductReceiptId  receipt;
    
    scope      = NumberSeqScopeFactory::createDataAreaScope(curext());
    nsRef      = NumberSeqReference::findReference(extendedTypeNum(VendAccount), scope);
    if (nsRef)
    {
        receipt         = NumberSeq::newGetNum( nsRef ).num();
    }
    
    info(strFmt("New Supplier Id is '%1'", receipt));
}

viernes, 31 de agosto de 2012

AX 2012 Post Purchase Order Intercompany Confirmation

I've been working with AX 2012, whoo-hoo!  Can't find anything in the new interface any more, they've moved a lot of the purchase/sales document processing code to run as a service and then I find myself helping junior developers with SSRS (reporting) without ever having touched the product.  All fun in my personal opinion, and am looking forward to playing some more with the product in the future.

Confirming a Purchase Order in X++ has already been done, but I thought I'd include the use case of when a client is marked as 'InterCompany', thereby the purchase orders are mimicked in another company but as a Sales Order for a supplier.  The issue is that once the sales order copy is created, it does not get confirmed as well (nor may it not accept the delivery note automatically once it is marked as dispatched).

Below is the code in it's simplest form.  Note: Ensure InterCompanyAutoCreateOrders and InterCompanyDirectDeliver are selected for the Sales Order.
static void tutorial_POConfirmJob(Args _args)
{    
    
    // Post Purch Confirmation, cross company 
    // --------------------------------------
    PurchFormLetter purchFormLetter;
    PurchTable      purchTable;
    ;
    changeCompany('qca')
    {
        purchTable      = purchTable::find('QCA-000041');
        purchFormLetter = PurchFormLetter::construct(DocumentStatus::PurchaseOrder);
        purchFormLetter.update(purchTable, strFmt('%1-%2', purchTable.PurchId, VendPurchOrderJour::numberOfPurchOrder(purchTable)+1));
    }
}

To plug the above into the existing framework try creating the following method in the SalesConfirmJournalPost class.
/// <summary>
///    Runs after posting a journal.
/// </summary>
public void postJournalPost()
{
    SetEnumerator   se = ordersPosted.getEnumerator();
    PurchFormLetter purchFormLetter;
    PurchTable      purchTable;
    
    ttsbegin;
    while (se.moveNext())
    {
        salesTable = SalesTable::find(se.current(),true);
        if (salesTable && strLen(salesTable.InterCompanyCompanyId) > 0
                    && strLen(salesTable.InterCompanyPurchId) > 0)
        {
            // Post Purch Confirmation, cross company.
            changeCompany(salesTable.InterCompanyCompanyId)
            {
                purchTable      = purchTable::find(salesTable.InterCompanyPurchId);
                if (purchTable
                        && purchTable.DocumentState < VersioningDocumentState::Confirmed)
                {
                    purchFormLetter = PurchFormLetter::construct(DocumentStatus::PurchaseOrder);
                    purchFormLetter.update(purchTable, 
                            strFmt('%1-%2', purchTable.PurchId, 
                              VendPurchOrderJour::numberOfPurchOrder(purchTable)+1));
                    
                    if (purchTable::find(salesTable.InterCompanyPurchId).DocumentState 
                                  == VersioningDocumentState::Confirmed)
                    { 
                        info(strfmt('Intercompany PO '%1' has also been Confirmed.', purchTable.PurchId));
                    }
                }
            }

        }
    }
    ttscommit;
}

Obviously, please test the code. Yours truly will not be held responsible for creating phantom stock in one of your companies!

viernes, 24 de agosto de 2012

Form Running Totals


I can think of two use cases when applying running totals to a grid on a form. Taking the LedgerTransAccount form (Account Transactions) as an example:

Running totals by transaction date

Add a real field to the grid, with a 'XXX_RunningBalanceMST' DataMethdod with the 'LedgerTrans' DataSource.
Add the following method to the LedgerTrans table:
public AmountMST XXX_RunningBalanceMST()
{
    LedgerTrans     ledgerTrans;
    ;
    select sum(AmountMST) from ledgerTrans
            where ledgerTrans.AccountNum  == this.AccountNum
               && ( (ledgerTrans.TransDate < this.TransDate)
                 || (ledgerTrans.TransDate == this.TransDate && ledgerTrans.RecId <= this.RecId) );
    return ledgerTrans.AmountMST;
}

Add the cache method to the form datasource (LedgerTrans) init() method, after the super(); call.
    ledgerTrans_ds.cacheAddMethod(tablemethodstr(LedgerTrans, XXX_RunningBalanceMST));

Running totals by user defined filters, dynalinks and ordenations:

This time add a disply method directly to the datasource.
//BP Deviation Documented
display AmountMST XXX_runningBalanceMST2(LedgerTrans _ledgerTrans)
{
    QueryRun    qr = new QueryRun(ledgerTrans_qr.query());
    LedgerTrans localLedgerTrans;
    AmountMST   amountMST;
    ;
    while (qr.next())
    {
        localLedgerTrans    = qr.get(tablenum(LedgerTrans));
        amountMST           += localLedgerTrans.AmountMST;
        if (localLedgerTrans.RecId == _ledgerTrans.RecId)
        {
            break;
        }
    }
    return amountMST;
}

This is definately the slowest procedure.  I'd avoid applying this modification to be honest.
We would have to implement our own caching on the results, using a map, with RecId as our key and the result as the value.  Initialise/empty the map in the executeQuery() method and check if the _ledgerTrans.RecId key exists in the map before launching into the loop in the method.  The optimisation is undertaken here.

EDIT: Includes user filters on the form columns:-
//BP Deviation Documented
display AmountMST XXX_runningBalanceMST(LedgerTrans _trans)
{
    LedgerTrans localLedgerTrans;
    AmountMST   amountMST;
    ;
    localLedgerTrans    = this.getFirst();
    while (localLedgerTrans)
    {
        amountMST           += localLedgerTrans.AmountMST;
        if (localLedgerTrans.RecId == _trans.RecId)
        {
            break;
        }
        localLedgerTrans    = this.getNext();
    }
    return amountMST;
}
Thanks to Jan B. Kjeldsen for the heads-up.

martes, 21 de agosto de 2012

That company container you have always been looking for


// Inner function to return a list of non virtual companies
// --------------------------------------------------------
container companyContainer(NoYes _includeVirtual = NoYes::No)
{
    DataArea            dataarea;
    container           retVal;
    ;
    while select id, isVirtual from dataarea index hint id
    {
        if (_includeVirtual == NoYes::No && dataarea.isVirtual == NoYes::Yes)
        {
            continue;
        }
        retVal += dataarea.id;
    }
    return retVal;
}

I have used the above inner function in two Jobs now, placed in the function declaration section.  Example usage:
static void tutorial_Job_companyCurrency(Args _args)
{
    container               conComps,
                            conComp;
    Counter                 cntComps;
    DataAreaId              dataareaId;
    CurrencyCode            currencyCompany;

    container companyContainer(NoYes _includeVirtual = NoYes::No)
    {
        DataArea            dataarea;
        container           retVal;
        ;
        while select id, isVirtual from dataarea index hint id
        {
            if (_includeVirtual == NoYes::No && dataarea.isVirtual == NoYes::Yes)
            {
                continue;
            }
            retVal += dataarea.id;
        }
        return retVal;
    }
    ;

    conComps        = companyContainer();

    for (cntComps=1; cntComps <= conlen(conComps); cntComps++)
    {
        dataareaId  = conpeek(conComps, cntComps);
        conComp     = [dataareaId];

        currencyCompany     = (select crosscompany : conComp CompanyInfo).CurrencyCode;
        warning(strfmt("Company: %1; Currency: %2", dataareaId, currencyCompany));
    }
}

Finally, below is an example when the customers table is shared across companies, using virtual companies in the query.
    DataArea            dataArea;
    VirtualDataAreaList virtualDataAreaList, 
                        virtualDataAreaListNow;
    ;
    select virtualDataAreaListNow
        where virtualDataAreaListNow.id == curext();

    while select dataArea
        where dataArea.isVirtual == false
//            && dataArea.Id != curext()
        join virtualDataAreaList
            where virtualDataAreaList.id == dataArea.id
            &&    virtualDataAreaList.virtualDataArea == virtualDataAreaListNow.virtualDataArea
    {
        changecompany(dataArea.Id)
        {
            info(strFmt('%1: %2', dataArea.Id, CustTable::find('CL000000').openBalanceMST()));
        }
    }

Let's put that in a tidy container:
static void BigJob(Args _args)
    container                   conCompanys;

    container companyContainer()
    {
        DataArea            dataarea;
        container           retVal;
        VirtualDataAreaList virtualDataAreaList,
                            virtualDataAreaListNow;
        ;
        select virtualDataAreaListNow
            where virtualDataAreaListNow.id == curext();

        while select dataArea
            where dataArea.isVirtual == false
            join virtualDataAreaList
                where virtualDataAreaList.id == dataArea.id
                &&    virtualDataAreaList.virtualDataArea == virtualDataAreaListNow.virtualDataArea
        {
            retVal += dataarea.id;
        }
        return retVal;
    }
    ;
    conCompanys = companyContainer();
    // Do work
}

martes, 14 de agosto de 2012

Decimal Number Formatting

Well I can't explain why but we kept losing decimal places when converting a real to a string.  The below function does *not* truncate the real value to two decimal places.  Note that the function is located in a utility class, and will execute on the server.
public static server str getExchRateCalc(real _fixedExchRate)
{
;
    new InteropPermission(InteropKind::ClrInterop).assert();
    return System.String::Format("{0,0:G}",(_fixedExchRate));
}

viernes, 13 de julio de 2012

The command line 'runas' with Ax

We can test user groups and access rights for individual users within our own work environment using the runas facility, removing the need to authenticate across multiple desktop environments:

AX 2009
C:\Windows\System32\runas.exe /user:JANE.DOE@DOMAIN.local "C:\Program Files (x86)\Microsoft Dynamics AX\50\Client\Bin\Ax32.exe"
Or with an AXC file

C:\Windows\System32\runas.exe /user:JANE.DOE@DOMAIN.local "C:\Program Files (x86)\Microsoft Dynamics AX\50\Client\Bin\Ax32.exe \"\\Servidor-Datos\Perfiles AX\AX_2009_VAR.axc"
AX 2012
C:\Windows\System32\runas.exe /user:JANE.DOE@DOMAIN.local "C:\Program Files (x86)\Microsoft Dynamics AX\60\Client\Bin\Ax32.exe"
Or with an AXC file
C:\Windows\System32\runas.exe /user:JANE.DOE@DOMAIN.local "\"C:\Program Files (x86)\Microsoft Dynamics AX\60\Client\Bin\Ax32.exe\" \"\\Servidor-Datos\Perfiles AX\AX_2012_USR.axc\""

We can now create various short-cuts on our desktop for various users to test our security configuration changes but you will still need to know their password! The easiest thing to do is create dummy users in the domain and add them to the various user groups.

AX2012 + Security Development Tool 
Finally there is an excellent add-on for an AX 2012 development environment, the Security Development Tool.  A white paper by Junction Solutions (a Microsoft Partner) can be downloaded here, see page 11 and enjoy some pain-free testing!

lunes, 25 de junio de 2012

Invalid Descriptor Index

Error:
[Microsoft][SQL Server Native Client 10.0]Invalid Descriptor Index
I've seen this error twice now in Ax 2009, and it's time to tell the world about it.  We have an external SQL Server database to import and register new invoices.  Example:
SELECT InvoiceSeriesNum, Note, ServiceDate, ExchRateSales FROM SomeTable WHERE Transferred=0
Using the OdbcConnection/Statement/ResultSet/LoginProperty classes we can import the data.  Code snippet:
    _intCustInvoiceTable.AssetId            = _resultSet.getString(#IntCustInvoice_AssetId);
    _intCustInvoiceTable.InvoiceSeriesNum   = _resultSet.getString(#IntCustInvoice_SeriesNum);
    _intCustInvoiceTable.Note               = _resultSet.getString(#IntCustInvoice_Note);
    _intCustInvoiceTable.ServiceDate        = _resultSet.getDate(#IntCustInvoice_ServiceDate);
    _intCustInvoiceTable.ExchRateSales      = _resultSet.getReal(#IntCustInvoice_ExchRateVend);
It happens that we now have a new field to add to the query. Thus:
SELECT InvoiceSeriesNum, Note, ServiceDate, ExchRateSales, AnotherField FROM SomeTable WHERE Transferred=0
    _intCustInvoiceTable.AssetId            = _resultSet.getString(#IntCustInvoice_AssetId);
    _intCustInvoiceTable.InvoiceSeriesNum   = _resultSet.getString(#IntCustInvoice_SeriesNum);
    _intCustInvoiceTable.Note               = _resultSet.getString(#IntCustInvoice_Note);
    _intCustInvoiceTable.ServiceDate        = _resultSet.getDate(#IntCustInvoice_ServiceDate);
    _intCustInvoiceTable.AnotherField       = _resultSet.getReal(#IntCustInvoice_AnotherField);
    _intCustInvoiceTable.ExchRateSales      = _resultSet.getReal(#IntCustInvoice_ExchRateVend);
You'll notice, unlike me for the past two hours, that we've added the AnotherField out of order while reading it in from the ResultSet.  By moving it down to the same index as the modified query it should work.  The problem is most likely caused by reading columns out of order.  To resolve the error in the example above, simply move our new field highlighted in red down a line, or edit the original query to change the columns around.

miércoles, 13 de junio de 2012

Hotfix my Hotfix

I have just descovered a Hotfix which fixes a problem when a Hotfix is applied and erases the SysHotfixManifest class, thereby removing my only chance of finding out what Hotfixes have been applied.  I'll need a lie down now.

You can see an example of the SysHotfixManifest class here, listing each of the Knowledge Base patches applied as individual method names.

lunes, 11 de junio de 2012

Production environment problems with BI

I mentioned before that we were working with OLAP cubes and now is that giddy time to publish our work to the production environment.  However, when launching Administration » Setup » Business analysis » OLAP Administration the client application hangs with no idea as to what caused the crash.  It would seem that we were missing a component to interact with the Analysis service, which is obvious if I had bothered to read the installation documentation, page 51.

The missing component is the SQL Server Analysis Management Objects (AMO), which I am now installing on my computer.
I was able to install the AS 2008 October feature pack (X64) on an Ax client without the need to restart the OS nor even the AOS service if the client is on the same machine as the server.


I'm so happy it works that I've even added a screenie of the thing filling up my precious BIEXCHANGERATES and SRSANALYSISENUMS tables.

Time to reprocess those cubes!...

Edit:  Agh!  I've lost my Spanish labels associated with the enumerations mentioned earlier.  This means that if my cube was filtering for the description of a transaction type enumeration in Spanish I'd only ever get 'Desconocido' or 'Unknown'.  This time I went directly to the BIUDMTRANSLATIONS table and manually checked the en-us, es, it, etc options to include these texts.

jueves, 31 de mayo de 2012

Configuring the Company Information Report Folder

While configuring the M-15 report for the Russia GLS layer in Ax 2009  I ran into the following error:

File C:\Exports\en-us_SalesInvoice_M15.xlt not found

I had set up the Company Information (CompanyInfo) Report Folder value incorrectly, and it should point to a directory 'inside' the Ax installation, holding report templates.  The location on the server will be similar to:

C:\Program Files\Microsoft Dynamics AX\50\Appliation\Share\Include\template\

In our case only two templates exist for the M15 report, in Russian and American English.  I really need to investigate this functionality for future development as it's a very versatile way of creating client reports.

jueves, 17 de mayo de 2012

Item type change from Item to Service

We've generated an error changing an item type to that of a Service.

In English:
Item type cannot be changed to %1 because inventory value has been posted for the item.
First set inventory value to 0.
In Spanish:
No se puede cambiar el tipo de artículo a %1, ya que el valor del inventario se ha registrado para el artículo.
Primero fijar el valor del inventario en 0.

Navigating to the item form (InventTable) and searching for the item we can see that the Availability of the item is not set to zero, we have some in stock.  We found a suggestion that we should therefore close off the inventory (MSDN), although manually updating the available quantity to zero (Inventory management > Forms > Item details > Available > Adjust Quantitywould work if there was no asociated inventory value in the purchase or sales order line for example...  Instead of closing the inventory I opted to adjust the posted value in the transactions to zero (Inventory management > Periodic > Closing and adjustment > Adjust > Transactions). 


In code use the InventItemTypeChange class to update item types, although in the job below I changed the visibility on the buildConfirmTxt static method to public...  For brevity and changes applied by the Eastern Europe/Russia GLS layer (GEEU tags in the code).
static void Job1(Args _args)
{
    InventItemTypeChange    iitc;
    container               confirmTxt;
    ItemType                itemType = ItemType::Service;
    ItemId                  itemId   = "YourItemId";
    #define.posCanChangeType(1)
    ;

    confirmTxt = InventItemTypeChange::buildConfirmTxt(itemId, itemType);
    if (conpeek(confirmTxt, #posCanChangeType) == true)
    {
        iitc        = new InventItemTypeChange();
        iitc.parmItemId(itemId);
        iitc.parmItemType(itemType);
        iitc.run();
    }
}

miércoles, 18 de abril de 2012

BI in AX with TMP


Working with Business Intelligence / Analysis Services I've come to realise how much we rely on temporary tables to do our work within Ax.  When only working at the database level we can't call Ax classes to generate our data.  My most recent issue was developing reports for budgeting purposes within the accounting module, when looking up budgetary balances within a financial period for example.  The LedgerPeriodTimeDimensions entity comes to our aid by generating a complete breakdown of those financial periods but this is a snap-shot of time and will not be updated as new periods are added.  As Sebastian Ohlsson points out:

...the recommendation from Microsoft is to make a backup of your database...
You have a table in Ax called LedgerPeriodTimeDimensions, it reflects Ax' ledger periods. This table needs to be updated as soon as you have modified the ledger periods. You update this table by going to Administration » Setup » Business analysis » OLAP Administration. Mark the OLAP server that should be updated.
Go to the Advanced tab and mark Update BI data  and click the Update databases button. After you have updated this table you need to process your cubes.

I had been relying on this entity for my work but didn't realise this dependency until I read the LedgerPeriodTimeDimensions table description within MSDN.  Why was this entity not prepended with the 'BI' or the 'SRS' label?

Edit: Execute the following in a job to just update those Enum changes in the SRSANALYSISENUMS table.
BIGenerator::PopulateSRSAnalysisEnums();

lunes, 9 de abril de 2012

Adding a secondary Cust/Vend address via X++

We're integrating with an external system once more.  This time we need to add an invoice address to the clients we are importing.  We already have a primary mail address, but on some occasions the invoices are directed to a department full of some patient ladies with very thick skin.
 
I find the way Ax 2009 deals with the global address book quite complicated but help is on hand in the form of a white paper (AX 2012 version).  However, I started by looking at the CustTable.deliveryAddress() and the Address.insert(..., ...) methods.

Below is an example of adding and editing the secondary invoice address:

static void Job1(Args _args)
{
    CustTable       custTable;
    DirPartyTable   dirPartyTable;
    Address         invoiceAddress;
    ;
    
    custTable       = CustTable::find("1");
    dirPartyTable   = DirPartyTable::find( custTable.PartyId );
    
    // Find private address for invoicing
    invoiceAddress = Address::find(dirPartyTable.TableId, dirPartyTable.RecId, AddressType::Invoice, true);
    if (!invoiceAddress)
    {
        invoiceAddress.AddrRecId    = dirPartyTable.RecId;
        invoiceAddress.AddrTableId  = dirPartyTable.TableId;
        invoiceAddress.type         = AddressType::Invoice;
        invoiceAddress.IsPrimary    = NoYes::No;
    }

    invoiceAddress.Name             = "Invoice Addressee";
    invoiceAddress.CountryRegionId  = "ES";
    invoiceAddress.Street           = "Calle de los perdidos";
    invoiceAddress.ZipCode          = "50000";
    invoiceAddress.City             = "Zaragoza";
    invoiceAddress.County           = "ZARAGOZA";
    invoiceAddress.State            = "AR";

    invoiceAddress.AddressMap::formatAddress();
    if (invoiceAddress.RecId == 0)
    {
        invoiceAddress.insert(true, false);
    }
    else
    {
        invoiceAddress.update(true, false);
    }
}

viernes, 30 de marzo de 2012

Development -> Preproduction -> Production Server

In theory our preferred server setup would be the title of this blog entry.  We can be a bit less robust with out development environment but the pre' and production servers should be in almost exactly the same state.  However:

What if the client hasn't bought a licence for developing with X++ and relies on their Microsoft Partner to do so.  AND what if the client the partner has is an all singing all dancing licence instead of being exactly the same as their client, plus the development modification.  One of the issues I've found was with deleting company data where we couldn't eliminate company (dataarea) related data as we no longer possessed the licence key for that part of the application.
I assume the error is caused by not possessing the licence key to part of the production module.
Applying the all singing all dancing licence finally allowed us to eliminate the company.  The proper licence was then reapplied once more.  When duplicating company data ensure that you have the 'proper' licence installed before exporting across servers! 

sábado, 10 de marzo de 2012

jueves, 1 de marzo de 2012

Setting up a One-Time Batch Job via X++


Here's the premise.  We are inserting some kind of transaction records and need to generate an alert if a collection of entries arrive that are of interest to a group of users.  Adding code to the XTrans.insert() method is our first step but spamming the users with multiple alerts is not satisfactory and so instead we generate a one time batch job to create just one message containing a list of these transaction.  The job will have a delay of 30 seconds before firing (say) but as new transactions arrive we will reset the launch time to another 30 seconds if we find that there is an existing job still pending execution.  In a high usage Ax installation the delay from 30 seconds would be reduced or quite simply this whole approach would be inappropriate.

I've taken code from the Setting up Batch jobs using X++ blog post and modified it so that the job launches only once.  I've also added code examples of terminating the job via the other two alternative methods (no end date or end by date).  Use of the SysRecurrence class is recommended to update the associated periodic data but note that the recurrence data is a container and therefore the setter functions in this class will return a new container.

A word of warning: debugging the server side batch job is well documented and well tricky!

static server void Tutorial_RunBaseTest(Args _args)
{
    BatchHeader         header;
    SysRecurrenceData   sysRecurrenceData;
    Batch               batch;
    BatchJob            batchJob;
    BatchInfo           processBatchInfo;
    BatchRetries        noOfRetriesOnFailure = 4;
    Tutorial_RunbaseBatch accAlerts_rbb;
    #define.timeInSecondsDelay(30)
    ;

    // Create the Tutorial_RunbaseBatch job, only if one does not exist
    select forupdate batch
        join batchJob
        where batchJob.RecId == batch.BatchJobId
                && batch.ClassNumber == classnum(Tutorial_RunbaseBatch)
                && batchJob.Status == BatchStatus::Waiting
                && batch.Company == curext();
    if (!batch)
    {
        // Setup the RunBaseBatch Job
        header = BatchHeader::construct();
        accAlerts_rbb = Tutorial_RunbaseBatch::construct();
        processBatchInfo = accAlerts_rbb.batchInfo();
        processBatchInfo.parmRetriesOnFailure(noOfRetriesOnFailure);
        header.addTask(accAlerts_rbb);
        // batchJob.OrigStartDateTime = DateTimeUtil::addSeconds(DateTimeUtil::utcNow(), #timeInSecondsDelay);

        // Set the recurrence data
        sysRecurrenceData = SysRecurrence::defaultRecurrence();
        // Start time {now + 30 seconds}
        sysRecurrenceData = SysRecurrence::setRecurrenceStartDateTime(sysRecurrenceData, DateTimeUtil::addSeconds(DateTimeUtil::utcNow(), #timeInSecondsDelay));
        // No end date
        //sysRecurrenceData = SysRecurrence::setRecurrenceNoEnd(sysRecurrenceData);
        // Finish after X times
        sysRecurrenceData = SysRecurrence::setRecurrenceEndAfter(sysRecurrenceData, 1);
        // End by {now + 1 day}
        //sysRecurrenceData = SysRecurrence::setRecurrenceEndAfterDate(sysRecurrenceData, DateTimeUtil::date( DateTimeUtil::addDays(DateTimeUtil::utcNow(), 1) ));

        header.parmRecurrenceData(sysRecurrenceData);
        // Set the batch alert configurations
        header.parmAlerts(NoYes::No, NoYes::Yes, NoYes::No, NoYes::Yes, NoYes::No);
        header.save();
    }
    else
    {
        // Push the exec time back 30 seconds - Avoid spamming the users with multiple warnings.
        ttsbegin;
        select forupdate batchJob
            join batch
            where batchJob.RecId == batch.BatchJobId
                && batch.ClassNumber == classnum(Tutorial_RunbaseBatch)
                && batch.Company == curext();

        sysRecurrenceData = batchJob.RecurrenceData;
        // Start time {now + 30 seconds}
        sysRecurrenceData = SysRecurrence::setRecurrenceStartDateTime(sysRecurrenceData, DateTimeUtil::addSeconds(DateTimeUtil::utcNow(), #timeInSecondsDelay));
        batchJob.RecurrenceData = sysRecurrenceData;
        batchJob.OrigStartDateTime = DateTimeUtil::addSeconds(DateTimeUtil::utcNow(), #timeInSecondsDelay);
        batchJob.update();
        ttscommit;
    }

    // Eliminate previous Cancelled and Finished jobs
    ttsbegin;
    while select forupdate batchJob
        join batch
            where batchJob.RecId == batch.BatchJobId
                && batch.ClassNumber == classnum(Tutorial_RunbaseBatch)
                && (batchJob.Status == BatchStatus::Finished
                    || batchJob.Status == BatchStatus::Canceled)
                && batch.Company == curext()
    {
        batchJob.delete();
    }
    ttscommit;
}

martes, 21 de febrero de 2012

Transaction Text

The Base > Config > "Transaction Texts" has a desceptively simple funcionality on the face of it yet provides us with a very useful tool. All of our ledgers can now be filled with wonderful texts that include the list I've copy/pasted from Microsoft below:
  • The Date field. To enter the date in this field, type %1.
  • The ID of program used to generate the information field. To enter this information, type %2.
  • The Voucher number field. To enter the voucher number or the vendor invoice number in this field, type %3.
  • Parameters %4 through %6 are reserved for user adjustments. These parameters can be used only by the system administrator.
That is to say you are just going to have to suck it and see for the %4 to %6 values.

However, when the client asks you to put the name of the client/supplier in the %4 field (warning: the transaction 'txt' is of somewhat limited space) they look at you incredulously as you can't give them a quick estimation as to how long it'll take. The problem being that yes, the TransactTxt table and the corresponding TransactionTxt class are very simple but we don't know where they are used. The key, so to speak, is with the LedgerTransTxt enumeration. Find where it is used in the classes and we are good.

My conclusion is that the changes themselves aren't difficult to undertake. It's just finding which classes to change and then applying rigorous testing post development to ensure that we aren't modifying other ledger texts other than what we desire.



The last part of this post is probably uninteresting to you, but these are notes to myself now ;)

The image below are what the analyst selected to add our client/supplier name functionality using the '%4' tag.  I've highlighted the registering of sales service and sales order invoices as an example.  This is a non exhaustive list and there are many other transaction types we can hook our free text on.

And below are indicators as to the changes to the classes I made:

Clientes -> Registrar factura del Pedido de venta

 * LedgerTransTxt::SalesInvoiceLedger : @SYS13480 : Pedido de ventas - Factura, Contabilidad
 - Asientos (LedgerTrans)
 - SalesFormLetter_Invoice.initTransactionTxt(...)

 * LedgerTransTxt::SalesInvoiceCust   : @SYS9935  : Pedido de ventas - Factura, Cliente
 - Cliente transacciones (CustTrans), Asientos (LedgerTrans)
 - CustVendVoucher.setTransactionTxt(...)

Clientes -> Factura de Servicios -> Registrar: 

 * LedgerTransTxt::CustInvoiceLedger  : @SYS57440 : Cliente - Factura, Contabilidad
 - Asientos (LedgerTrans)
 - CustPostInvoice.run(...) -> 169

 * LedgerTransTxt::CustInvoiceCust    : @SYS57441 : Cliente - Factura, Cliente
 - Cliente transacciones (CustTrans), Asientos (LedgerTrans)
 - CustVendVoucher.setTransactionTxt(...)

Proveedores -> Registrar factura del pedido de compra

 * LedgerTransTxt::PurchInvoiceLedger : @SYS2653  : Pedido de compra - Factura, Contabilidad
 - Asientos (LedgerTrans)
 - PurchFormLetter_Invoice.initTransactionTxt(...)

 * LedgerTransTxt::PurchInvoiceVend   : @SYS14768 : Pedido de compra - Factura, Proveedor
 - Proveedor transacciones (VendTrans), Asientos (LedgerTrans)
 - CustVendVoucher.setTransactionTxt(...)

Clientes -> Registrar factura del Pedido de venta con una cantidad negativa

 * LedgerTransTxt::SalesCreditNoteLedger : @SYS6176 : Pedido de ventas - Nota de abono, Contabilidad
 - Asientos (LedgerTrans)
 - SalesFormLetter_Invoice.initTransactionTxt(...)

 * LedgerTransTxt::SalesCreditNoteCust : @SYS9529 : Pedido de ventas - Nota de abono, Cliente
 - Cliente transacciones (CustTrans), Asientos (LedgerTrans)
 - CustVendVoucher.setTransactionTxt(...)

Proveedores -> Registrar factura del Pedido de compra con una cantidad negativa

 * LedgerTransTxt::PurchCreditNoteLedger : @SYS11877 : Pedido de compra - Nota de abono, Contabilidad
 - Asientos (LedgerTrans)
 - PurchFormLetter_Invoice.initTransactionTxt(...)

 * LedgerTransTxt::PurchCreditNoteVend   : @SYS13408 : Pedido de compra - Nota de abono, Proveedor
 - Proveedor transacciones (VendTrans), Asientos (LedgerTrans)
 - CustVendVoucher.setTransactionTxt(...)

Clientes -> Factura de servicios -> Registrar -VE cantidad 

 * LedgerTransTxt::CustCreditNoteLedger : @SYS57442 : Cliente - Nota de abono, Contabilidad  
 - Asientos (LedgerTrans)
 - CustPostInvoice.run(...) -> 169

 * LedgerTransTxt::CustCreditNoteCust   : @SYS57443 : Cliente - Nota de abono, Cliente
 - Cliente transacciones (CustTrans), Asientos (LedgerTrans)
 - CustVendVoucher.setTransactionTxt(...)

Pendiente!

 * LedgerTransTxt::VendCashPayment      : @SYS28685 : Proveedor - Pago en efectivo
 - (VendTransData.cashledgerTransTxt())
 * LedgerTransTxt::CustCashPayment      : @SYS28684 : Cliente - Pago en efectivo
 - (CustTransData.cashledgerTransTxt())

Proveedores -> Diario de Pagos/Cobros -> Propuesta de pago
Proveedores -> Pagares -> Diario de creación de pagares -> Propuesta de pago
Proveedores -> Pagares -> Diario de envíos -> Propuesta de pago
Proveedores -> Pagares -> Diario de liquidación de pagos -> Propuesta de pago
Proveedores -> Diario de Pagos/Cobros -> Liquidación (LedgerJournalTrans.PaymentNotes)
Proveedores -> Pagares -> Diario de creación de pagares -> Liquidación (LedgerJournalTrans.PaymentNotes)


 * LedgerTransTxt::VendPaymentVend    : @SYS1390  : Proveedor - Pago, Proveedor
 - Diario de pagos (LedgerJournalTrans), Proveedor transacciones (VendTrans), Asientos (LedgerTrans)
 - CustVendPaymProposalTransferToJournal.getTransactionText() - Texto de la transacción   (LedgerJournalTrans.Txt)
 - CustVendPaymNote.buildPaymNote() -> 74                     - Nota asociado con el pago (LedgerJournalTrans.PaymentNotes)

Clientes -> Diario de Pagos/Cobros -> Propuesta de pago
Clientes -> Letra de cambio -> Diario de creación de letra de cambio -> Propuesta de pago
Clientes -> Letra de cambio -> Diario de envíos -> Propuesta de pago
Clientes -> Letra de cambio -> Diario de liquidación... -> Propuesta de pago
Clientes -> Diario de Pagos/Cobros -> Liquidación (LedgerJournalTrans.PaymentNotes)
Clientes -> Letra de cambio -> Diario de envíos -> Liquidación (LedgerJournalTrans.PaymentNotes)

 * LedgerTransTxt::CustPaymentCust    : @SYS60760 : Cliente - Pago, Cliente
 - Diario de pagos (LedgerJournalTrans), Cliente transacciones (CustTrans), Asientos (LedgerTrans)
 - CustVendPaymProposalTransferToJournal.getTransactionText() - Texto de la transacción   (LedgerJournalTrans.Txt)
 - CustVendPaymNote.buildPaymNote() -> 74                     - Nota asociado con el pago (LedgerJournalTrans.PaymentNotes)

-------------------
¿¿¿ Not found or used ???
 * LedgerTransTxt::CustBillOfExchangeLedger : @SYS71601 : Cliente - Letra de cambio, contabilidad
 - NOT USED?

 * LedgerTransTxt::CustBillOfExchangeCust   : @SYS71602 : Cliente - Letra de cambio, cliente
 - NOT USED?

 * LedgerTransTxt::VendPaymentLedger        : @SYS14411 : Proveedor - Pago, Contabilidad
 - NOT USED?

 * LedgerTransTxt::VendPromissoryNoteVend   : @SYS71605 : Proveedor - Pagaré, Proveedor
 - NOT USED?

 * LedgerTransTxt::VendPromissoryNoteLedger : @SYS71604 : Proveedor - Pagaré, Contabilidad
 - NOT USED?


viernes, 10 de febrero de 2012

My first CrossCompany update

Objective: From one 'template' company within Ax 2009 apply two of the InventTable columns to multiple other companies.  All companies possess the same articles/items identifier data, it was just a case of updating the Item Group, and the Name Alias fields which we use as a 'translation' from the parent company - they have companies in Poland, Russia, Morocco,..

Here I was interested in learning:
  • Use a CrossCompany declaration inside the query, instead of using a ChangeCompany(...) method before calling the query.
  • Test if we could apply a transaction across companies - Yes we can!  At the database level all data in the below example sits in the same table anyway.
static void Update_InventTable()
{
    CompanyId               companyDestination, companySource;

    InventTable             tableSource, tableDestination;
    ItemId                  itemId;
    ItemGroupId             itemGrpId;
    ItemNameAlias           nameAlias;

    Dialog                  dialog;
    DialogField             field1;

    List                    lstCosicas;
    ListIterator            iterator;
    #define.splitter        (',')

    SysOperationProgress    progress = new SysOperationProgress();
    #AviFiles

    #define.companySource       ('BAI')    //curext()
    #define.companyDestination  ('AML,GLO,ITA,JML,MAP,MAR,MARR,MPOL,POL,RUS,SANT,SID,TOW')
    container               conDestination, conSource;
    int                     iCnt;
    ;

    dialog          = new Dialog("Apply inventory item update");
    dialog.addText(strfmt("¿Apply Article Group and Name Alias fields from company '%1' to:", #companySource));
    dialog.addText("     " + #companyDestination);
    field1          = dialog.addFieldValue(typeId(CompanyId), #companySource, "Choose source company");
    dialog.run();
    if (dialog.closedOK())
    {
        companySource = field1.value();
    }
    else
    {
        return;
    }

    progress.setCaption('Updatind Articles...');
    progress.setAnimation(#AviUpdate);

    // Create company containers for use in some exciting nested cross-company queries
    lstCosicas  = strSplit(#companyDestination, #splitter);
    iterator    = new ListIterator(lstCosicas);
    while (iterator.more())
    {
        companyDestination      = iterator.value();
        conDestination          += companyDestination;
        iterator.next();
    }
    conSource       += #companySource;

    // Let's update the InventTable in the multiple companies, across only one fat transaction
    ttsbegin;
    // Obtain the fields from the Source Company
    while select crosscompany : conSource ItemId, ItemGroupId, NameAlias, DataAreaId from tableSource order by ItemId
    {
        itemId      = tableSource.ItemId;
        itemGrpId   = tableSource.ItemGroupId;
        nameAlias   = tableSource.NameAlias;

        iCnt        = 0;

        setprefix(          strfmt("From Company %1, applying article '%2' across other companies", tableSource.dataAreaId, itemId) );
        progress.setText(   strfmt("From Company %1, applying article '%2' across other companies", tableSource.dataAreaId, itemId) );
        while select forupdate crosscompany : conDestination tableDestination where tableDestination.ItemId == itemId
        {
           changecompany(tableDestination.dataAreaId)
           {
                      tableDestination.ItemGroupId     = itemGrpId;
                      tableDestination.NameAlias       = nameAlias;
                      tableDestination.update();
           }
           iCnt++;
        }
        info(strfmt("Article '%2' updated in %1 companies.", iCnt, itemId));
    }
    ttsCommit;

    info('End');
}

My doubts and thoughts are:

  • Changing company has a certain 'cost' involved and so I assume applying the CrossCompany feature at the query level will be more efficient than nesting a 'find' method in a ChangeCompany?
  • How to apply the above update in an even more efficient manner? I can't see CrossCompany in the update_recordset documentation, and it explicitly states that CrossCompany is not implemented within the delete_from and insert_recordset clauses.
  • Aside from having to build the containers for the CrossCompany feature, I like it for legibility/readability within the code.  The ChangeCompany method, however, is more 'obvious' when skimming through someone else's code.  It also seems to be obligatory if you need to update the data. 

lunes, 6 de febrero de 2012

The Endless Progress Bar/Indicator


One of the most complete posts I've seen about the Progress Bar/Indicator is by that of mfp.  One tidbit of useful information we can glean from the text is that by not setting the total setTotal(...) number of iterations we won't see the progress from 0 to X but still have the animation for the user to 'enjoy' watching.  Sometimes we neither know nor care for the percentage of completion of our task.
If you do not specify the total, the progress indicator and the time remaining will not be shown.
We therefore can exclude the incCount() call as well:
    SysOperationProgress  progress = new SysOperationProgress();
    #AviFiles
    int k;
    ;
    progress.setCaption('@SYS8516'); //Updating...
    progress.setAnimation(#AviUpdate);

    // It'll be 10 passes today, 2 million in the production environment!
    for (k=0; k<10; k++) {
        progress.setText( strfmt("Update step %1", k+1) );
        sleep(2000);
    }