Automate “Edit Dimensions” to assign Batchnumbers (FIFO)

Situation:
  • All finished products and sub-assemblies are batch controlled, but the assignment of the batches is not always known in advance and in other cases mixed batches are used which is also not handled automatically. More technical: Blank receipts are not allowed, but blank issuing is.
  • For our purposes FIFO is good enough for assigning the batches to transactions after the fact.

Solution:

  • The following methods identify existing batches and transactions without batchnumber assign.
  • The positiv and negative quantities are matched and if nescessary some transactions are split.
  • (The following static methods are part of the BWBatches class)

//bw start
//Changed on 30 May 2007 by TW
/* Description:
This method with fix the Batchnumber assignment of the item identified by the parameter
In the method the is only the identification of the positive and negative quantities.
In the end the auxillary method fixBatchAssignmentItemMatch is used to match positive and negative quantities.
And the auxillary method fixBatchAssignmentItemEdit is used to edit the transactions.
*/
public static void fixBatchAssignmentItem(ItemId _itemId)
{
    InventBatch inventBatch;
    InventOnhand inventOnhand;
    InventDim   inventDim;
    InventTable item = InventTable::find(_itemId);
    Qty         remaining;
    array       positives = new array(Types::Container); //positives container: inventDimId, qty, prodDate
    Int         positivesI = 0;
    array       negatives = new array(Types::Container); //negatives container: inventDimId, qty, prodDate
    Int         negativesI = 0;
    array       matches; //matches container: pos inventDimId, neg inventDimId, qty, pos prodDate
    Int         i;
    boolean     hasInventDimIdBlank = false;
    ;

    setprefix(item.ItemId);
    //———————-find stock levels for all batch numbers——————————–
    //—————add non-zero stock levels to postive or negative lists————————
    while select inventBatch order by prodDate asc where inventBatch.itemId == item.ItemId
    {
        inventOnhand = InventOnhand::newInventBatch(inventBatch);
        remaining = inventOnhand.physicalInvent();
        if (remaining)
        {
            inventDim = null;
            inventDim.inventBatchId = inventBatch.inventBatchId;
            inventDim = InventDim::findOrCreate(inventDim);

            if (remaining<0) //add negative stock levels to negatives array
            {
                negativesI++;
                negatives.value(negativesI,[inventDim.inventDimId,remaining, inventBatch.prodDate]);
            }
            else if (remaining >0) //add positive stock levels to positives array
            {
                positivesI++;
                positives.value(positivesI,[inventDim.inventDimId,remaining, inventBatch.prodDate]);
            }
        }
    }
    //add empty dimension last
 
   inventDim = InventDim::findOrCreateBlank(false);
    inventOnhand = InventOnhand::newItemDim(item.ItemId,inventDim,InventDimParm::activeDimFlag(item.DimGroupId));
    remaining = inventOnhand.physicalInvent();
    if (remaining<0) //add negative stock levels to negatives array
    {
        hasInventDimIdBlank=true;
        negativesI++;
        negatives.value(negativesI,[inventDim.inventDimId,remaining,1\1\1900]);
    }
    else if (remaining >0) //add positive stock levels to positives array
    {
        hasInventDimIdBlank=true;
        positivesI++;
        positives.value(positivesI,[inventDim.inventDimId,remaining,1\1\1900]);
    }

    //———————-match positives and negatives——————————–
    //———————————————————————————–
    if (negativesI || hasInventDimIdBlank)//only negatives und unassigned batches are a problem
    {
        matches = BWBatches::fixBatchAssignmentItemMatch(positives,negatives,item);

        //———————-edit dimensions of transactions——————————
        //———————————————————————————–
        BWBatches::fixBatchAssignmentItemEdit(matches,item);
    }
}
//bw end

 

//bw start
//Changed on 22 May 2007 by TW
/* Description:
match positive and negative stocklevels of an item
_positives, _negatives: array of containers with three elements [inventDimId, qty, prodDate]
return value: array of containers with 4 elements [pos inventDimId, neg inventDimId, qty, pos prodDate]
*/
client server private static array fixBatchAssignmentItemMatch(array _positives, array _negatives, InventTable _item)
{
    int         i,j;
    Container   posC;
    Qty         posQty;
    Container   negC;
    Qty         negQty;
    Date        batchDate,batchDate2;
    array       matches = new array(Types::Container); //container elements [pos inventDimId, neg inventDimId, qty, pos prodDate]
    int         matchesI;
    ;

    for (i=1; i<=_positives.lastIndex(); i++)//run through all negative stock levels
    {
        posC = _positives.value(i); //_positives container elements [inventDimId, qty, prodDate]
        posQty = conpeek(posC,2);
        batchDate = conpeek(posC,3);
        if (posQty)
        {
            for (j=1; j<=_negatives.lastIndex(); j++)//try to match negative stock levels with positiv stock levels
            {
                negC = _negatives.value(j); //_negatives container elements [inventDimId, qty, prodDate]
                negQty = conpeek(negC,2);
                batchDate2 = conpeek(negC,3);
                if (negQty)
                {
                    if (posQty >= -negQty) //more that enough available -> use all needed (-negQty)
                    {
                        matchesI++;
                        matches.value(matchesI,[conpeek(posC,1),conpeek(negC,1),-negQty, batchDate]);
                        negQty -= negQty; //reduce needed Qty (=0)
                        posQty += negQty; //reduce available Qty
                    }
                    else //if (posQty < -negQty) //not enough available -> use all available (posQty)
                    {
                        matchesI++;
                        matches.value(matchesI,[conpeek(posC,1),conpeek(negC,1),posQty, batchDate]);
                        negQty += posQty; //reduce needed Qty
                        posQty -= posQty; //reduce available Qty (=0)
                    }
                    _negatives.value(j,[conpeek(negC,1),negQty,batchDate2]); //update needed value
                }
            }
            _positives.value(i,[conpeek(posC,1),posQty,batchDate]); //update available value
        }
    }
    //finally check if any negatives remain unmatched
    for (j=1; j<=_negatives.lastIndex(); j++)
    {
        negC = _negatives.value(j);
        negQty = conpeek(negC,2);
        if (negQty)
            error(strfmt("Can not completely resolve stocklevels of %1. Please fix manually.",_item.ItemId),"",SysInfoAction_TableField::newBuffer(_item));
    }
    return matches;
}
//bw end

 

//bw start
//Changed on 22 May 2007 by TW
/* Description:
edit dimensions of transactions
_matches: array of containers with 4 elements [pos inventDimId, neg inventDimId, qty, pos prodDate]
*/
private static void fixBatchAssignmentItemEdit(array _matches, InventTable _item)
{
    #OCCRetryCount
    int     i;
    Qty     remaining;
    Date    batchDate;
    InventDim   inventDimPos;
    InventDim   inventDimNeg;
    InventTrans inventTrans;
    ;

    try
    {
        ttsbegin;
        for (i=1;i<=_matches.lastIndex();i++)
        {
            //_matches container elements [pos inventDimId, neg inventDimId, qty, pos prodDate]
            inventDimPos = InventDim::find(conpeek(_matches.value(i),1));
            inventDimNeg = InventDim::find(conpeek(_matches.value(i),2));
            remaining = conpeek(_matches.value(i),3);
            batchDate = conpeek(_matches.value(i),4);

            remaining = -remaining; //running from the negative perspective

            //fix negative (i.e. issues with unassigned or wrongly assign batch numbers)
            //there might be many transactions with the same BatchId
            if (inventDimNeg.inventBatchId <= inventDimPos.inventBatchId)
            {  
                //the issued batch (or blank batch) will be fullfilled by newer batch
                //run through through all transactions by order by date DESCENDING
 
               while select forupdate inventTrans order by DatePhysical desc
                                        where inventTrans.ItemId == _item.ItemId
                                            && inventTrans.inventDimId == inventDimNeg.inventDimId
                {
                    //execute changes:
 
                   remaining = BWBatches::auxBatchAssignment(inventTrans,remaining,inventDimPos,inventDimNeg);
                    if (!remaining)
                        break;
                }
            }
            else
            {
                //the issued batch will be fullfilled by older batch
                //run through through all transactions by order by date ASCENDING
 
               while select forupdate inventTrans order by DatePhysical asc
                                        where inventTrans.ItemId == _item.ItemId
                                            && inventTrans.inventDimId == inventDimNeg.inventDimId
                                            && inventTrans.DatePhysical >= batchDate
                {
                    //execute changes:
                    remaining = BWBatches::auxBatchAssignment(inventTrans,remaining,inventDimPos,inventDimNeg);
                    if (!remaining)
                        break;
                }
            }
            if (remaining)
                error(strfmt("Can not completely resolve stocklevels of %1. Please fix manually.",_item.ItemId),"",SysInfoAction_TableField::newBuffer(_item));
        }
        ttscommit;
    }
    catch (Exception::Deadlock)
    {
        if (xSession::currentRetryCount() >= #RetryNum)
            throw Exception::UpdateConflictNotRecovered;
        else
            retry;
    }
}
//bw end

 

//bw start
//Changed on 30 May 2007 by TW
/* Description:
Auxiliary method that assign a new batchnumber to a transaction and splits the transaction if nescessary
*/
private static qty auxBatchAssignment(InventTrans _inventTrans, Qty _remaining, InventDim _inventDimPos, InventDim _inventDimNeg)
{
    InventTrans inventTrans = _inventTrans;
    Qty remaining = _remaining;
    InventDim inventDimPos=_inventDimPos;
    InventDim inventDimNeg=_inventDimNeg;
    InventTransSplit    inventTransSplit;
    ;
    if (remaining>0 || inventTrans.Qty>0)
    {
        error(strfmt("auxBatchAssignment assertion: remaining (%1) and inventTrans.Qty (%2) are expected to be less than zero.",remaining,inventTrans.Qty));
        return 0;
    }
   
    if (-inventTrans.Qty <= -remaining)
    {//The transactions qty is smaller than the required quantity
     // -> use all (i.e. change dimension for entire transaction)
       
inventTrans.inventDimId = inventDimPos.inventDimId; //assign the new batch number
        inventTrans.update();
        BWBatches::auxBatchAssignmentRef(inventTrans); //change associated sales and production lines
        info(strfmt("%3: %2 %4 -> %2 %5",inventTrans.InventTransId, inventTrans.Qty, inventTrans.DatePhysical,
                        inventDimNeg.inventBatchId?inventDimNeg.inventBatchId:inventDimNeg.inventDimId,inventDimPos.inventBatchId?inventDimPos.inventBatchId:inventDimPos.inventDimId));
        remaining -= inventTrans.Qty;
    }
    else if (-inventTrans.Qty > -remaining)
    {//The transactions qty is larger than the required quantity
     // -> use only part of the transaction (i.e. it is nescessary to plit the transaction!)
        inventTransSplit = InventTransSplit::newInventTransSplit(inventTrans.RecId);
        warning(strfmt(strfmt("Spliting %1: %2 %5 -> %3 + %4 %5",inventTrans.DatePhysical, inventTrans.Qty, remaining, inventTrans.Qty – remaining, (inventDimNeg.inventBatchId?inventDimNeg.inventBatchId:inventDimNeg.inventDimId))));
        inventTransSplit.parmSplitQty(inventTrans.Qty – remaining); //inventTrans will contain the desired qty
        try
        {
            inventTransSplit.run();
        }
        catch (Exception::Error)
        {
            error(strfmt("Can not split transaction. Please fix stocklevels of %1 manually.",inventTrans.ItemId),"",SysInfoAction_TableField::newBuffer(InventTable::find(inventTrans.ItemId)));
            return 0;
        }
        inventTrans = InventTrans::findRecId(inventTrans.RecId,true); //reload the record (for update)
        inventTrans.inventDimId = inventDimPos.inventDimId; //assign the new batch number
        inventTrans.update();
        BWBatches::auxBatchAssignmentRef(inventTrans); //change associated sales and production lines
        info(strfmt("%3: %2 %4 -> %2 %5",inventTrans.InventTransId, inventTrans.Qty, inventTrans.DatePhysical,
                        inventDimNeg.inventBatchId?inventDimNeg.inventBatchId:inventDimNeg.inventDimId,inventDimPos.inventBatchId?inventDimPos.inventBatchId:inventDimPos.inventDimId));
        remaining -= inventTrans.Qty;
    }
    return remaining;
}
//bw end

 

//bw start
//Changed on 30 May 2007 by TW
/* Description:
The auxilary method replicated the inventTrans changes to sales lines and production lines.
*/
public static void auxBatchAssignmentRef(InventTrans _inventTrans)
{
    SalesLine salesline;
    ProdBom   prodBOM
    ;

    select forupdate firstonly salesline where salesline.InventTransId == _inventTrans.InventTransId;
    if (salesline)
    {
        if (salesline.QtyOrdered == _inventTrans.Qty && salesline.InventDimId != _inventTrans.inventDimId)
        {
            salesline.InventDimId = _inventTrans.inventDimId;
            salesline.update();
        }
    }
    else
    {
        select forupdate firstonly prodBOM where prodBOM.InventTransId == _inventTrans.InventTransId;
        if (prodBOM)
        {
            if (prodBOM.QtyInventCalc == _inventTrans.Qty && prodBOM.InventDimId != _inventTrans.inventDimId)
            {
                prodBOM.InventDimId = _inventTrans.inventDimId;
                prodBOM.update();
            }
        }
    }
}
//bw end

 

Assets: multiple depreciation rates with identical posting profile

Issue:
  • Changing legislation leads to many different depreciation rates depending on the time an asset was acquired.
  • Dynamics Ax requires one value model per depreciation rate, i.e. we need around 30 value models.
  • Each value model requires a specific posting profile. Each posting profile has at least 14 entries. Leading to over 400(!) entries.
  • Every new Asset requires the setting up of a new posting profile (if the depreciation rate is again different).

Solution:

  • Allow the system to fall back to a default posting profile attached to the value model "Std".
  • Make the modification that still allow multiple posting profiles if needed, i.e. first try to use standard functionality, only if failed fall back to a default posting profile.
  • The following methods must be modified:
    • Table AssetLedgerAccounts
      • find
      • findAssetLedgerAccount
      • findAssetLedgerOffsetAccount
      • assetLedgerAccount
      • assetLedgerOffsetAccount
      • exist
    • Class AssetReclassification
      • findNewAssetLedgerAccount
      • findOldAssetLedgerAccount
    • Table AssetDisposalParameters
      • find
      • findPosintingAccound
      • exist
    • Class AssetPostDisposal
      • post
  • The modifications all follow the same pattern: Find the query(ies) in the method and repeat them with BookId == "Std".
    General pseudo code structure:
    found = select1 where BookId == _bookid;
    if (!found)
        found  = select2 where BookId == _bookid;
    //new:
    if (!found)
        found  = select1 where BookId ==  "Std";
    if (!found)
        res = select2 where BookId ==  "Std;

public static boolean exist( //table AssetLedgerAccounts
    AssetBookId         _bookId,
    AssetPostingProfile _postingProfile,
    AssetTransType      _transType,
    AssetTableGroupAll  _accountCode,
    AccountNum          _accountRelation)
{
    boolean found;
    ;

    found = (select firstonly
                RecId
             from
                assetLedgerAccounts
             where
                assetLedgerAccounts.BookId == _bookId &&
                assetLedgerAccounts.PostingProfile == _postingProfile &&
                assetLedgerAccounts.TransType == _transType &&
                assetLedgerAccounts.AccountCode == _accountCode &&
                assetLedgerAccounts.AccountRelation == _accountRelation).RecId != 0;

//bw start
//Changed on 21 May 2007 by TW
/* Description:
Default to the Value Model "Std" if no specific BookId found.
*/

    if (!found)
    {
        found = (select firstonly
                RecId
             from
                assetLedgerAccounts
             where
                assetLedgerAccounts.BookId == "Std" &&
                assetLedgerAccounts.PostingProfile == _postingProfile &&
                assetLedgerAccounts.TransType == _transType &&
                assetLedgerAccounts.AccountCode == _accountCode &&
                assetLedgerAccounts.AccountRelation == _accountRelation).RecId != 0;
    }
//bw end

    return found;
}

  • AssetPostDisposal is the only exception to this simple pattern. As there is a while select loop, the situation needs to be handled slightly different

void post() //class AssetPostDisposal
{
    AssetAmount                 assetAmount;
    AssetDisposalParameters     assetDisposalParameters;
    AssetDisposalParameters     l_assetDisposalParameters;
    AssetPostType               assetPostType;
    AssetSoldScrap              soldScrap;
    CurrencyCode                companyCurrency = CompanyInfo::standardCurrency();
    LedgerVoucherTransObject    ledgerVoucherTransObject;
    boolean                     useStd = false; //bw
    ;

    ttsbegin;

    […]

    select assetDisposalParameters
          where assetDisposalParameters.PostValue       == AssetPostValue::NBV  &&
                assetDisposalParameters.SoldScrap       == soldScrap            &&
                assetDisposalParameters.PostingProfile  == assetTrans.PostingProfile   &&
                assetDisposalParameters.BookId          == assetTrans.BookId    &&
                (assetDisposalParameters.ValueType      == assetPostType ||
                 assetDisposalParameters.ValueType      == AssetPostType::All)  &&
                ((assetDisposalParameters.AssetCode     == TableGroupAll::Table   &&
                  assetDisposalParameters.AssetRelation == assetTrans.AssetId) ||
                 (assetDisposalParameters.AssetCode     == TableGroupAll::GroupId &&
                  assetDisposalParameters.AssetRelation == AssetTable::find(assetTrans.AssetId).AssetGroup) ||
                  assetDisposalParameters.AssetCode     == TableGroupAll::All);
//bw start
//Changed on 21 May 2007 by TW
/* Description:
Default to the Value Model "Std" if no specific BookId found.
*/
    if (!assetDisposalParameters)
    {
        useStd = true;
        select assetDisposalParameters
          where assetDisposalParameters.PostValue       == AssetPostValue::NBV  &&
                assetDisposalParameters.SoldScrap       == soldScrap            &&
                assetDisposalParameters.PostingProfile  == assetTrans.PostingProfile   &&
                assetDisposalParameters.BookId          == "Std"    &&
                (assetDisposalParameters.ValueType      == assetPostType ||
                 assetDisposalParameters.ValueType      == AssetPostType::All)  &&
                ((assetDisposalParameters.AssetCode     == TableGroupAll::Table   &&
                  assetDisposalParameters.AssetRelation == assetTrans.AssetId) ||
                 (assetDisposalParameters.AssetCode     == TableGroupAll::GroupId &&
                  assetDisposalParameters.AssetRelation == AssetTable::find(assetTrans.AssetId).AssetGroup) ||
                  assetDisposalParameters.AssetCode     == TableGroupAll::All);
    }
//bw end
    if (!assetDisposalParameters.RecId)
    {
            throw error(strfmt("@SYS24602","@SYS67345" + ‘/’ + "@SYS67538", "@SYS67500"));
    }

    while select assetDisposalParameters
        group by PostValue
        where assetDisposalParameters.SoldScrap       == soldScrap                    &&
              assetDisposalParameters.PostingProfile  == assetTrans.PostingProfile    &&
//bw start
//Changed on 21 May 2007 by TW
/* Description:
if mod was needed above use Std else use BookId
*/
              assetDisposalParameters.BookId          == (useStd?"Std":assetTrans.BookId)            &&
//bw end
              (assetDisposalParameters.ValueType      == assetPostType                ||
               assetDisposalParameters.ValueType      == AssetPostType::All)
    {
        l_assetDisposalParameters = AssetDisposalParameters::findPostingAccount(assetTrans.AssetId,
                                                                                assetDisposalParameters.SoldScrap,
                                                                                assetDisposalParameters.PostingProfile,
                                                                                assetDisposalParameters.BookId,
                                                                                assetDisposalParameters.ValueType,
                                                                                assetDisposalParameters.PostValue);

        assetAmount = this.postValue(l_assetDisposalParameters.PostValue);
        if (assetAmount)
        {
            ledgerVoucherTransObject = LedgerVoucherTransObject::newCreateTrans(
                                                                    ledgerVoucher.findLedgerVoucherObject(),
                                                                    LedgerPostingType::FixedAssetsDebit,
                                                                    l_assetDisposalParameters.Account,
                                                                    assetTrans.Dimension,
                                                                    companyCurrency, //assetTrans.currencyCode,
                                                                    -assetAmount,
                                                                    assetTrans.TableId,
                                                                    assetTrans.RecId,
                                                                    0);
            ledgerVoucherTransObject.parmOperationsTax(LedgerVoucher::operationsTax(AssetBookTable::find(assetTrans.BookId).CurrentOperationsTax));
            ledgerVoucherTransObject.parmTransTxt(assetTrans.Txt);
            ledgerVoucher.addTrans(ledgerVoucherTransObject);

            ledgerVoucherTransObject = LedgerVoucherTransObject::newCreateTrans(
                                                                    ledgerVoucher.findLedgerVoucherObject(),
                                                                    LedgerPostingType::FixedAssetsDebit,
                                                                    l_assetDisposalParameters.OffsetAccount,
                                                                    assetTrans.Dimension,
                                                                    companyCurrency, //assetTrans.currencyCode,
                                                                    assetAmount,
                                                                    assetTrans.TableId,
                                                                    assetTrans.RecId,
                                                                    0);
            ledgerVoucherTransObject.parmOperationsTax(LedgerVoucher::operationsTax(AssetBookTable::find(assetTrans.BookId).CurrentOperationsTax));
            ledgerVoucherTransObject.parmTransTxt(assetTrans.Txt);
            ledgerVoucher.addTrans(ledgerVoucherTransObject);
        }
    }
    ttscommit;
}

Identify production quantity including route scrap before posting

Situation:

  • Allow back-flushing of 92Kg of ingredient, even though only 90Kg are available; No negative stock allowed.
  • I did this by modifying the production order before reporting as finished.
  • For this I needed a way to find out how much the system will back-flush (e.g. 92Kg). The difficulty is that we use phantom BOMs and Routes, which makes the calculation rather complicated.
  • NOTE: Activating the production parameter "Physical reduction" (Journals tab), also reduces the quantity automatically to depleat only available stock. But we wanted more controll over this process.

Solution:

  • Access the functionality used by Dynamics Ax for this calculation.
  • Note: The production must be started for the phantoms to be exploded.

//bw start
//Changed on 22 May 2007 by TW
/* Description:
Get the expected BOM line qty to be used
Production must be started to take Phantom BOM/Routes into account
*/
static public Qty prodItemQty(ProdBOM _prodBom, Qty _seriesSize=0)
{
    ProdTable           prodTable = ProdTable::find(_prodBom.BOMId);
    BOMCalcData         bomCalcData;
    BOMCalcConsumption  itemCalcLine;
    UnitQty             bomProposal;
    InventQty           inventProposal;
    Qty                 seriesSize = _seriesSize;
    ;
    if (!seriesSize)
    {
        if(prodTable.ProdStatus==ProdStatus::StartedUp)
            seriesSize = prodTable.QtyStUp-(prodTable.reportedFinishedGood()+prodTable.reportedFinishedError());
        else
            seriesSize = prodTable.QtySched;
    }
    bomCalcData = BOMCalcData::newProdTable(seriesSize,_prodTable);
    itemCalcLine = BOMCalcConsumption::construct(_prodBom.Formula,bomCalcData);
    bomProposal = itemCalcLine.calcConsumption(_prodBom,
                                                ProdRoute::accError(_prodBOM.ProdId,_prodBOM.OprNum),
                                                NoYes::Yes);
    inventProposal = UnitConvert::qty(
                bomProposal,
                _prodBOM.UnitId,
                InventTableModule::find(_prodBOM.ItemId,ModuleInventPurchSales::Invent).UnitId,
                _prodBOM.ItemId);

    return inventProposal;
}
//bw end

Abstract of my Ph.D. Thesis 2004: Estimating and Influencing the cost of ERP-Systems in Swiss SMB

Small and medium-sized enterprises make substantial investments in ERP-projects. Still, many do not know the costs associated with such projects and how they can influence the costs. This thesis addresses the needs of such businesses by offering a simple cost estimate together with various recommendations for influencing cost and success of ERP-projects. It is based on the analysis of about 40 project including 5 case studies.

Cost estimate

The cost drivers of ERP-systems are mainly the number of users and the complexity of the project. Many other aspects can influence the cost and might be a major issue in single projects, but they didn’t prove to be relevant in the general analysis.
Cost = a + b*Users+c*complexity+e
The formula was substantiated for small and medium-sized manufacturing companies in Switzerland using the data of 40 projects. It showed that the complexity of a project can be approximated by the team size. The accuracy of the estimates is limited by many factors which can not be accounted for in an early stage of the project. But it is similar to the accuracy of the observed budgets, which were based on much more thorough analyses.
ExpectedTotalCosts[CHF]=160’000+9’500*Users+56’000*Teamsize
ExpectedInvestmentCosts[CHF]=160’000+5’000*Users+34’000*Teamsize
Total project costs can be estimated better than investments alone, because the vendor decides according to his pricing policy to ask more up front – leading to higher investment costs – or to charge more maintenance costs later. Therefore it is strongly suggested that the maintenance costs get the necessary attention during the evaluation.
In practice many use the much simpler estimate, which depends only on the number of users. Often the complexity gets partly included by adjusting the cost per user. It showed that between Fr. 6’000 and 30’000 per user were used. The investment cost per user of the developed formula is bounded by the 95%-confidence interval between Fr. 10’000 and 15’000.
ExpectedTotalCosts[CHF]=20’000*Users
ExpectedInvestment[CHF]=12’000*Users

Recommendations for ERP-customers

ERP-customers who want to influence cost must be careful not to endanger the benefits of the system or the success of the project. Therefore factors which reduce costs and improve the success of the project have first priority when trying to reduce the cost. Avoidance of adaptations, investments in evaluation and raising the project competence as well as the capacity of the team members, are some of the important factors.
Service costs can be influenced best. They, on the other hand, depend largely on how much effort is being put into actual adaptations of standard ERP-solutions. Adaptations can be avoided by rethinking internal processes, by choosing the right ERP-System, and by refraining from implementing too many specific wishes. It can also be shown that avoiding adaptations doesn’t reduce the benefits of an ERP-project but even improves project success.
It cannot be expected of an average small or medium-sized business that its project managers or team members know who’s who in the ERP-market. Therefore it is strongly suggested that businesses invest adequately in evaluation by engaging independent consultants, to ensure that an optimal ERP-system gets chosen. A lot depends on the choice of the system, still only 26 % of the observed businesses called upon external help for their evaluations and the ones who did, spent only 1 % of the investment amount for this purpose.
Competence and capacity of the project manager and the team members have a strong impact on the success of a project. Therefore it is important that the most able staff members get released from a substantial part of their daily duties. Depending on the number of competent employees with free capacity, more tasks can be handled in-house, which further reduces external costs.

Recommendations for ERP-developers

These recommendations mainly address small, local developers which operate in the SME-market and who are often confronted with pressure from mighty international competitors such as Microsoft, SAP and other large-scale enterprises. A growth strategy is less applicable for the smaller developer since this means entering into direct competition with the big ones of the industry.
Proximity to customers is the key to success for small and local developers. Instead of imitating large competitors, strengthening the proximity to customers by focussing on a few branches of trade and regions, is the name of the game.
To be successful the developers should support the already identified recommendations for ERP-customers. A possible chance to do this is by reducing the software licences leaving hardware and service costs as the only investments. This makes the offer more attractive since it has lower risks and it makes it clearer to the customer that adaptations are the main source of higher investment costs.
Training costs represent an important part of total service cost. For this reason developers should focus on improving the ergonomics of a system rather than adding additional functionalities. Intuitive software helps users to learn faster and leads to higher motivation. More functionality on the other hand will hardly be noticed by a majority of users, because most up-to-date systems satisfy their needs with respect to functionality and therefore many of the hyped functions hardly get used.

Reporting as finished items with scrap on route – multiple reporting as finished

Situation:
  • Depending on the processing equipment we expect some percentage of the bulk to be waste e.g. 2% of 1000kg (i.e. when starting 980kg the system automatically reserves 1000kg of ingredients, 980kg are expected as good qty).
  • We have defined this as errorPct on the Workcenter, which gets copied to the route when created.
  • When processing we will start with ingredient to make 1000Kg but expect only 980Kg good qty the rest will be waste.
  • On the shop floor up to 5% might occur i.e. we might report 950 kg ( and 50 kg waste) as finished.
  • We will produce the 1000kg in two lots of 500kg and report them independently.

What DAX does:

  1. Create a job for 980 kg
  2. Start the job for 980 kg (reserves 1000kg of ingredients)
  3. Two reporting as finished scenarios:
    1. Report it as fished in two lots of 450 kg (unexpected high scrap of 50 kg per lot)
      1. First lot: the system suggests 980 kg good qty, no error qty; we enter 450kg / 40kg (assuming the system already accounts for 10 kg error qty)
      2. Second lot: the system suggest 10 kg good qty and 0 kg error qty! (Problem #1) Expected would be either 490 kg or 530 kg (in our situation 490 kg, as we will take what comes out of the process and will not add more ingredient to make good for error qty).
      3. Although all ingredients have been used up the system still expects 80kg more product to be finished. (Problem #2) The remain status will stay at Material consumption until the job is ended. The actual remain status should be Ended.
    2. Report it as fished in two lots of 495 kg (unexpected low scrap of 5 kg per lot)
      1. First lot: the system suggests 980kg good qty, no error qty; we enter 495kg / -5kg (assuming the system already accounts for 10 kg error qty)
      2. Second lot: the system suggest 0kg good qty and 15 kg error qty! (Problem #1) Expected would be either 490 kg or 485kg. We again enter 495kg / -5kg.
      3. Even though the system realises that no more qty is to be expected as finished (i.e. remain qty = 0) the remain status will still stay at Material consumption until the job is ended. (Problem #2) 
  4. Other combinations combinations of good qty and error quantity can be applied. All result in very weird behavior.
It is incomprehensible how anyone could use the system together with error quantities on the route. Luckily Dynamics Ax allows every customer to dive into the code an the culprits leading to Problem #1 are quickly found:
The class ProdUpdReportFinished has a method called proposalQtyGood and proposalQtyError the returns proposed good and error quantities.  Check the code below.
I will post the solution of Problem #2 it a future blog.
 
 
 
static InventQty proposalQtyGood(ProdId _prodId)
{
    ProdTable   prodTable              = ProdTable::find(_prodId);
    InventQty   routeReportedError     = prodTable.routeReportedError();
    InventQty   routeReportedTotal     = prodTable.routeReportedGood() + routeReportedError;
    InventQty   reportedFinishedGood   = prodTable.reportedFinishedGood();
    InventQty   reportedFinishedError  = prodTable.reportedFinishedError();
    InventQty   maxReportedError       = (reportedFinishedError < routeReportedError) ? routeReportedError : reportedFinishedError;
    InventQty   plannedOrder;
    ;
    if (prodTable.mandatoryRegister())
    {
        plannedOrder = InventTransSum::new2TransId().idRegistered(prodTable.InventTransId);
    }
    else
    {
//bw start
//Changed on 15 Mar 2007 by TW
/* Description:
Calculate the planned order qty relative to the qty started not relative to the qty on the route.
*/
        /* original code
        plannedOrder = routeReportedTotal;
        if (!plannedOrder)
        {
            plannedOrder= prodTable.QtyStUp;
            if (!plannedOrder)
                plannedOrder= prodTable.QtySched;
        }
        plannedOrder = plannedOrder – reportedFinishedGood – maxReportedError;
        */
        plannedOrder = prodTable.QtyStUp – reportedFinishedGood – reportedFinishedError;
//bw end
        if (plannedOrder < 0)
            plannedOrder = 0;
    }
    return plannedOrder;
}
 
static InventQty proposalQtyError(ProdId _prodId)
{
    InventQty   qtyError;
    ProdTable _prodTable= ProdTable::find(_prodId);
    ;
    if (_prodTable.mandatoryRegister())
        return 0;
    else
    {
        qtyError = _prodTable.routeReportedError() – _prodTable.reportedFinishedError();
        if (qtyError < 0)
            qtyError = 0;
    }
//bw start
//Changed on 15 Mar 2007 by TW
/* Description:
Never suggest an error Quantity!
The error Qty should only reflect finished product that has been compleated,
but that has failed some sort of quality check. I.e. this value is expected to be zero.
*/
    qtyError = 0;
//bw end
    return qtyError;
}