Tuesday, February 19, 2013

"Opportunities are so hard to find"

My users are having a hard time sifting through 100's of Opportunity records.  They want some way to quickly "see" the whole list and understand what is happening.  They also want to avoid creating duplicate Opportunity records.  This is really easy to do since my users work as a team on many accounts.

Auto-populating the Opportunity name upon creation

We briefly considered some automatic numbering scheme for Opportunity records.  But this only helped with uniquely identifying an Opportunity once it was created.   We then considered a "rule" that each team member would need to follow when naming the opportunity.   But, I do not want to ask my Sales users to stop and remember some "rule".  I want them out servicing our customers and selling.

So, I proposed we implement the rule in a trigger.  Here is how to do it.

First, I create a APEX class that will do the work.   I do not like to put much code into triggers because this separates the code from the unit tests (which must be in a class).  I really believe in putting the unit test code near to the work code for two reasons: (a) I can see both at the same time and reuse constants and help methods and (b) I don't like to pollute the global namespace with lots of class names.  Many other developers share this concern and we've asked SF to allow us to put classes into folders.  If you feel the way I do then vote for these ideas Enhanced Package/Namespace Support (like Java/.Net) and Visualforce pages, Apex class etc.. into folder.

The steps below are those I used to migrate from Test to Production.  In other words all of the following worked within a sandbox and these are the steps I used to copy to Prod.   Of course you will build this in a sandbox first, :)


Create the worker class and one worker method stub:

public with sharing class OpportunityUtilities {
 public static void ProcessOpportunityUpsert(List oppList) {
 }
}

Next create the trigger and call this work method on the updated items.  I like to name my triggers to indicate what they work on and when.

trigger OpportunityUpsertTrigger on Opportunity (before insert, before update) {

 OpportunityUtilities.ProcessOpportunityUpsert(trigger.new);
 
}

Aside:
As I attempt to write this blog post I'm stuck waiting and waiting for the Salesforce instance to respond.  This prompted me to update and re-post an old blog posting on speeding up development.   See http://sforcehacks.blogspot.ca/2013/02/speed-up-development-two-tips.html But even these tips didn't help and I've been stuck for a several of hours waiting for the Force IDE to save to server and run the unit tests.  

Next build the unit test (test driven development) within
OpportunityUtilities
 
The test creates the account name, a project location, a main product and the expected Opportunity name composed of all three.   In the test I select the user profile of the user type that normally works with Opportunities. This way the unit test also validates the code works for the expected user.
The special naming rules only are applied to a special record type.  This allows me to provide this special rule for one group of sales people while not affecting others.

 
// in class OpportunityUtilities 
static testMethod void runOpportunityUtilitiesTestCases() {
    Profile p = [select Id, Name from Profile where Name='Custom Profile']; 
    System.debug(p.Id + ' ' + p.Name);
    User u = [select Id, Name from User where IsActive = true AND ProfileId = :p.Id limit 1];
    System.debug(u.Id +  ' ' + u.Name);
    RecordType customType = [select Id, Name  from RecordType 
         where SobjectType = 'Opportunity' AND Name LIKE 'Custom%' limit 1];
    RecordType defaultType = [select Id, Name  from RecordType 
         where SobjectType = 'Opportunity' AND Name LIKE 'Default%' limit 1];
    System.debug(customType.Id +  ' ' +  customType.Name);
    System.runAs(u) {
        String testLocation = 'Some location with super long location name that is greater than thirty characters';
        String testAccountName = 'Some Account with super long name that is greater than forty characters';
        String mainProductValue = 'Some Product with a long name';
        String defaultOpportunityName = 'Some Project Name';
        String expectedOpportunityName = testAccountName.substring(0,40) 
              + SEP + testLocation.substring(0,30) 
              + SEP + mainProductValue.substring(0,10) + SEP;
        expectedOpportunityName = expectedOpportunityName.trim();
        Account act = new Account(Name=testAccountName, OwnerId = u.Id); 
        insert act;
        act = [select Id, RecordTypeLabel__c from Account where Name = :testAccountName limit 1];

        // custom record type
        Opportunity opp;
        opp = new Opportunity(Location__c=testLocation, 
             Main_Product__c= mainProductValue, AccountId = act.Id, 
             StageName='Prospecting', Name = defaultOpportunityName, 
             CloseDate = System.Date.today(), RecordTypeId = customType.Id);
        insert opp;
        opp = [select Id, Name from Opportunity where Id = :opp.Id];  
        System.debug('runOpportunityUtilitiesTestCases ' + opp.Id + ' "' + opp.Name + '" ?=? "' + expectedOpportunityName +'"');
        System.assert(expectedOpportunityName == opp.Name);
        
        // default record type
        opp = new Opportunity(Location__c=testLocation, 
             Main_Product__c= mainProductValue, AccountId = act.Id, 
             StageName='Prospecting', Name = defaultOpportunityName, 
             CloseDate = System.Date.today(), RecordTypeId = defaultType.Id);
        insert opp;
        opp = [select Id, Name from Opportunity where Id = :opp.Id];  
        System.debug('runOpportunityUtilitiesTestCases ' + opp.Id + ' "' + opp.Name + '" ?=? "' + defaultOpportunityName +'"');
        System.assert(defaultOpportunityName == opp.Name);
    }  
} 


Now, here is the worker method and the main method that calls it.

public static String SEP = ' - ';
public static void ProcessOpportunityUpsert(List oppList) {
 ProcessTrafficOpportunityUpsert(oppList);
}

/** ProcessTrafficOpportunityUpsert
 To help users to quickly scan a list of opportunities and see what is important we reformat the Name.

 For every Traffic Opportunity reformat the Opportunity Name to fit this pattern:
  Account Name - Location__c - Main_Product__C - Case Number
 The Name field is limited to 100 chars so we truncate the fields to:
  Account 40
  Location 30
  Main Product 10
 Case number is not truncated!
*/
public static void ProcessTrafficOpportunityUpsert(List oppList) {
    RecordType targetRecordType = [select Id, Name  from RecordType 
           where SobjectType = 'Opportunity' AND Name LIKE 'Traffic%' limit 1];
 List toProcessList = new List();
 for(Opportunity theRecord:oppList)
    {   
     if(theRecord.RecordTypeId == targetRecordType.id) {
      toProcessList.add(theRecord);
     }
    }
 if(toProcessList.size() > 0) {
  List accountIds = new List();
     List caseIds = new List();
     
     for(Opportunity theRecord: toProcessList) {   
         if(theRecord.AccountId!= null)
             accountIds.add(theRecord.AccountId);
         if(theRecord.Primary_Case__c!= null)
             caseIds.add(theRecord.Primary_Case__c);
     }
     List accountList = [select Id, Name from Account where id in :accountIds];
     List caseList = [select Id, CaseNumber from Case where id in :caseIds];
     Map nameMap = new Map();
     for(Account acc: accountList) {
         nameMap.put(acc.id, acc.Name);
     }   
     Map caseMap = new Map();
     for(Case cs: caseList) {
         caseMap.put(cs.id, cs.CaseNumber);
     }   
     for(Opportunity theRecord: toProcessList) {   
         String accountName = nameMap.get(theRecord.AccountId);
         if(accountName.length()>39)
          accountName = accountName.substring(0, 40);
      String location = theRecord.Location__c;
         if(location.length()>29)
          location = location.substring(0, 30);
      String mainProduct = theRecord.Main_Product__c;
         if(mainProduct.length()>10)
          mainProduct = mainProduct.substring(0, 10);
         String caseNumber = caseMap.get(theRecord.Primary_Case__c);
         String composedName = '';
         composedName += (accountName == null ? SEP : accountName + SEP);
         composedName += location + SEP;
         composedName += mainProduct + SEP;
         composedName += (caseNumber == null ? '' : caseNumber);       
         theRecord.Name = composedName;
     }
 }
} 
 
With the above in place our Sales users can not quickly scan the list of opportunities and see all of them sorted by Account, project, product, and case number

Next step,  I'll try to automatically create a Case to associate with the Opportunity.  With this we can easily track email communication associated with the opportunity. Simple:  just send the email to our main email address that we use for support.  My custom email handler locates the Case number in the subject and links the email to the Case.  With an easy to find custom field on the Opportunity record and a cross link from the Case to the Opportunity our Sales and Support people can keep close tabs on a project. 

  

No comments:

Post a Comment