Monday, April 23, 2012

Custom Bulk OpportunityLineItem Edit

So we have a business logic for handling annual maintenance contract renewals with our customers. The idea is that there is a Opportunity RecordType for renewal orders, and custom fields on the OpportunityLineItems (only accessible on the custom Opportunity type) for linking to the license records for the customer. (Custom objects are used to track the maintenance renewals.) When the Opportunity is closed, an Apex trigger creates the necessary custom objects to extend the maintenance expiration periods.

It's relatively easy to add custom formula fields to your "Opportunity Products", and you can add these fields to the "mini" Page Layout so that they appear on the Opportunity. But our reps routinely use the "Edit All" button to get the OpportunityLineItems in a grid layout and edit them all. The problem with this is that on the Edit All page there is no identifying information for each line item so it's a bit problematic and trial and error to update them all.

Professor Google showed me lots of people who felt that it should be very easy to use a VisualForce page with a custom controller to do the job. But it was short on details about how an Opportunity Controller could access the associated Line Item data. Lots of experimentation proved that it was actually very simple, once you realize that your page needs to use the Opportunity controller, not the OpportunityLineItem:
<apex:page standardController="Opportunity" showHeader="true">
  <apex:sectionHeader title="Bulk Edit for {!opportunity.Name}" description="Edit products for this opportunity." />
  <apex:messages />
  <apex:form >
  <apex:pageBlock >
  <apex:pageMessages />
  <apex:pageBlockButtons >
    <apex:commandButton value="Quick Save" action="{!quicksave}" />
    <apex:commandButton value="Save" action="{!save}" />
    <apex:commandButton value="Cancel" action="{!cancel}" />
  </apex:pageBlockButtons>
  
  <apex:pageBlockTable value="{!opportunity.OpportunityLineItems}" var="o">
    <apex:column headerValue="License" value="{!o.License_Master__r.Name}" />
    <apex:column headerValue="Product Code" value="{!o.License_Product_Code__c}" />
    <apex:column headerValue="End User" value="{!o.License_Master__r.End_User_Account__r.Name}" />
    <apex:column headerValue="Seats">
      <apex:inputField value="{!o.Quantity}" />
    </apex:column>
    <apex:column headerValue="List Price" value="{!o.MPU__c}" />
    <apex:column headerValue="Sales Price">
      <apex:inputField value="{!o.UnitPrice}" />
    </apex:column>
    <apex:column headerValue="Maintenance Term">
      <apex:inputField value="{!o.Maintenance_Term__c}" />
    </apex:column>
    <apex:column headerValue="SLA">
      <apex:inputField value="{!o.Maintenance_SLA__c}" />
    </apex:column>
    <apex:column headerValue="Maintenance Fee Override">
      <apex:inputFIeld value="{!o.Maintenance_Fee_Override__c}" />
    </apex:column>
  </apex:pageBlockTable>
  </apex:pageBlock>
  </apex:form>
</apex:page>
Ironically, by using the VisualForce page this way, it becomes very easy to add additional details from the related custom objects (for example, o.License_Master__r.End_User_Account__r.Name) without adding additional custom formula fields to the OpportunityLineItems. I suspect that if you want these fields displayed on the Opportunity detail page, you'd have to resort to formula fields.

While this page works great with respect to displaying the data we want to work with using standard Opportunity controller, clicking on the "Save" button only saves the Opportunity -- edits to the line items' details are lost. This can be corrected with a simple controller extension:
<apex:page standardController="Opportunity" extensions="OpportunityBulkLineItemEdit" showHeader="true">
  <apex:sectionHeader title="Bulk Edit for {!opportunity.Name}" description="Edit products for this opportunity." />
  <apex:messages />
  <apex:form >
  <apex:pageBlock >
  <apex:pageMessages />
  <apex:pageBlockButtons >
    <apex:commandButton value="Quick Save" action="{!quicksaveAll}" />
    <apex:commandButton value="Save" action="{!saveAll}" />
    <apex:commandButton value="Cancel" action="{!cancel}" />
  </apex:pageBlockButtons>
  
  <apex:pageBlockTable value="{!opportunity.OpportunityLineItems}" var="o">
    <apex:column headerValue="License" value="{!o.License_Master__r.Name}" />
    <apex:column headerValue="Product Code" value="{!o.License_Product_Code__c}" />
    <apex:column headerValue="End User" value="{!o.License_Master__r.End_User_Account__r.Name}" />
    <apex:column headerValue="Seats">
      <apex:inputField value="{!o.Quantity}" />
    </apex:column>
    <apex:column headerValue="List Price" value="{!o.MPU__c}" />
    <apex:column headerValue="Sales Price">
      <apex:inputField value="{!o.UnitPrice}" />
    </apex:column>
    <apex:column headerValue="Maintenance Term">
      <apex:inputField value="{!o.Maintenance_Term__c}" />
    </apex:column>
    <apex:column headerValue="SLA">
      <apex:inputField value="{!o.Maintenance_SLA__c}" />
    </apex:column>
    <apex:column headerValue="Maintenance Fee Override">
      <apex:inputFIeld value="{!o.Maintenance_Fee_Override__c}" />
    </apex:column>
  </apex:pageBlockTable>
  </apex:pageBlock>
  </apex:form>
</apex:page>
Along with the new controller extension APEX class:
public with sharing class OpportunityBulkLineItemEdit {
// Controller extension to enable saving line items edited in bulk
        private final Opportunity opp;
        private final ApexPages.StandardController sc;
        
        public OpportunityBulkLineItemEdit(ApexPages.StandardController stdCtrl) {
                this.sc = stdCtrl;
                this.opp = (Opportunity) this.sc.getRecord();
        }
        
        public PageReference saveAll() {
                update this.opp.opportunitylineitems;
                return this.sc.save();
        }
    
    public PageReference quicksaveAll() {
        update this.opp.opportunitylineitems;
        this.sc.save();
        return null;
    }
}
And, of course, it's tempting to not write tests for such a small amount of code, but in the interest of doing it right, I wrote some (that work for my environment, anyway). The trick to the tests was that we need to look up and use the products from our existing pricebooks, which as of API 23.0, are not available to tests, so we need to use the "UseAllData=true" directive:
public class OpportunityBulkLineItemEdit_Test {

    static OpportunityBulkLineItemEdit ext;
    static PageReference pref;
    static Opportunity opp;
    
    private static void init() {
        Account acct = new Account(Name = 'Test account');
        insert acct;
        
        RecordType rt = [SELECT Id FROM RecordType WHERE DeveloperName = 'SO_Maint_Renewal' AND sObjectType='Opportunity'];
        Pricebook2 pb = [SELECT Id From Pricebook2 WHERE IsStandard=true];
        
        Product2 prod = new Product2(
            Description = 'Test maintenance renewal product',
            Family = 'Maintenance',
            IsActive = true,
            Name = 'Maintenance Renewal'
            );
        insert prod;    
        
        PriceBookEntry pbe = new PricebookEntry(
            Product2Id = prod.Id,
            Pricebook2Id = pb.Id,
            UnitPrice = 0.00,
            IsActive = true,
            UseStandardPrice=FALSE
 );
        insert pbe;
        
        opp = new Opportunity(
            AccountId = acct.Id,
            Name = 'auto',
            RecordTypeId = rt.Id,
            CloseDate = date.today(),
            StageName = 'Upside',
            Pricebook2Id = pb.Id,
            Account_Rep__c = UserInfo.getUserId()
        );
        
        insert opp;

        List<license_master__c> licenses = [SELECT Id, Name FROM License_Master__c LIMIT 10];
        List<opportunitylineitem> items = new List<opportunitylineitem>();
        
        for(Integer count = 1; count < 3; count++)
        {
            OpportunityLineItem oli = new OpportunityLineItem(
                OpportunityId = opp.Id,
                PricebookEntryId = pbe.Id,
                Quantity = count * 2,
                UnitPrice = count * 10,
                License_Master__c = licenses.get(count).Id
            );
            items.add(oli);
        }
        insert items;
        
        pref = Page.BulkOLIEdit;
        pref.getParameters().put('id',opp.Id);
        Test.setCurrentPage(pref);
        
        ApexPages.StandardController con = new ApexPages.StandardController(opp);
        ext = new OpportunityBulkLineItemEdit(con);
    }
    
    @isTest (SeeAllData=true)
    static void testSaveAll()
    {
        init();
        
        Test.startTest();
        ext.saveAll();
        Test.stopTest();
    }
    
    @isTest (SeeAllData=true)
    static void testQuicksaveAll()
    {
        init();
        Test.startTest();
        ext.quicksaveAll();
        Test.stopTest();
    }  
}
And there, for posterity, is how I have implemented this functionality. Enjoy!

Search This Blog