Unlocking the Potential of Salesforce CPQ: A Deep Dive into the Line Editor Price Analyzer
The realm of Salesforce CPQ (Configure, Price, Quote) is a complex one, filled with intricate configurations, price calculations, and quote generation processes. For businesses operating at scale, the ability to efficiently analyze and optimize pricing is not just a nice-to-have, it's a critical component of the sales process. This is where the Line Editor Price Analyzer comes into play.
What is the Line Editor Price Analyzer?
Developed by Matthew Seeger, the Line Editor Price Analyzer is an innovative tool designed for Salesforce CPQ that aids in visualizing and analyzing product pricing directly from the line editor. It is a Visualforce page enhanced with Apex controllers and client-side scripts, which together provide a dynamic and interactive pricing analysis interface.
How Does it Enhance the Salesforce CPQ Experience?
The Price Analyzer tool is a prime example of extending Salesforce CPQ's capabilities. It provides sales representatives with immediate insights into historical pricing data, such as maximum, average, and minimum quoted prices. These insights empower sales teams to make informed pricing decisions, leading to more competitive quotes and ultimately, successful deals.
Key Features of the Line Editor Price Analyzer
- Product-Specific Analysis: The Analyzer is designed to fetch and display data for specific products, allowing for a granular look at pricing trends.
- Historical Price Points: By displaying historical price data such as the highest, average, and lowest prices, the Analyzer gives context to current pricing strategies.
- Dynamic Charts: Utilizing Chart.js and Salesforce Lightning Design System (SLDS), the Analyzer provides visual representations of pricing history and volume analysis through scatter and line charts.
- Customizable Metrics: Sales teams can define what metrics are relevant to them and have those displayed prominently.
Technical Breakdown
The tool consists of two main components:
1. Visualforce Page: This is the user interface of the Analyzer. It uses SLDS for styling and incorporates Chart.js for rendering charts. The page is connected to Salesforce data via a custom Apex controller.
<apex:page title="Price Analyzer" applyBodyTag="False" applyHtmlTag="False" showHeader="false" standardStylesheets="false" sidebar="false" docType="html-5.0" controller="PriceAnalyzerController">
<html xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<head>
<apex:slds />
<apex:includeScript value="https://cdnjs.cloudflare.com/ajax/libs/jsforce/1.7.0/jsforce.min.js" />
<apex:includeScript value="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.0/Chart.bundle.min.js"/>
</head>
<body>
<div class="slds-scope">
<div class="slds-page-header">
<div class="slds-media">
<div class="slds-media__figure">
<span class="slds-icon_container slds-icon-standard-poll">
<svg class="slds-icon slds-page-header__icon" aria-hidden="true">
<use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href='/assets/icons/standard-sprite/svg/symbols.svg#poll' />
</svg>
</span>
</div>
<div class="slds-media__body">
<h1 class="slds-page-header__title slds-truncate slds-align-middle" title="Price Analyzer">Price Analyzer</h1>
<p class="slds-text-body_small slds-line-height_reset">{!productObj.Name}</p>
</div>
</div>
</div>
<div class="demo-only demo-only--sizing slds-grid slds-wrap">
<div class="slds-size_1-of-7">
<div class="slds-section slds-is-open">
<h3 class="slds-section__title slds-theme_shade">
<span class="slds-truncate slds-p-horizontal_small" title="Price Metrics">Price Metrics</span>
</h3>
<div aria-hidden="false" class="slds-section__content">
<div class="slds-box slds-m-vertical_xx-small">
<div class="slds-text-title_caps slds-m-bottom_xx-small">Highest Price</div>
<div class="slds-text-heading_medium">
<apex:outputText value="{0, number, currency}">
<apex:param value="{!high}" />
</apex:outputText>
</div>
</div>
<div class="slds-box slds-m-vertical_xx-small">
<div class="slds-text-title_caps slds-m-bottom_xx-small">Average Price</div>
<div class="slds-text-heading_medium">
<apex:outputText value="{0, number, currency}">
<apex:param value="{!avg}" />
</apex:outputText>
</div>
</div>
<div class="slds-box slds-m-vertical_xx-small">
<div class="slds-text-title_caps slds-m-bottom_xx-small">Lowest Price</div>
<div class="slds-text-heading_medium">
<apex:outputText value="{0, number, currency}">
<apex:param value="{!low}" />
</apex:outputText>
</div>
</div>
</div>
</div>
</div>
<!-- History Chart -->
<div class="slds-size_4-of-7">
<div class="slds-section slds-is-open slds-p-horizontal_xx-small">
<h3 class="slds-section__title slds-theme_shade">
<span class="slds-truncate slds-p-horizontal_small" title="Pricing History">Pricing History</span>
</h3>
<div aria-hidden="false" class="slds-section__content">
<div class="chart-container" style="height:350px; width:99%">
<canvas id="timechart"></canvas>
</div>
</div>
</div>
</div>
<!-- Scatter Chart -->
<div class="slds-size_2-of-7">
<div class="slds-section slds-is-open">
<h3 class="slds-section__title slds-theme_shade">
<span class="slds-truncate slds-p-horizontal_small" title="Volume Analysis">Volume Analysis</span>
</h3>
<div aria-hidden="false" class="slds-section__content">
<div class="chart-container" style="height:350px; width:99%">
<canvas id="chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<script>
/**
Scatter plot
*/
function processChartData(err, res){
console.debug('*CPQ_PA Processing Scatter Plot');
if(err){
console.error(err);
return;
}
console.debug('*CPQ_PA Processing Results:');
console.debug(res);
var thisData = [{x:{!param1},y:{!param2}}]
var wonData = [];
var activeData = [];
var lostData = [];
for(var i = 0; i< res.records.length; i++){
wonData.push({x:res.records[i].SBQQ__Quantity__c,y:res.records[i].SBQQ__NetPrice__c});
lostData.push({x:res.records[i].SBQQ__Quantity__c,y:res.records[i].SBQQ__NetPrice__c});
activeData.push({x:res.records[i].SBQQ__Quantity__c,y:res.records[i].SBQQ__NetPrice__c});
}
var chartParams = {
type: 'scatter',
data: {
datasets: [
{label: 'Won',data: wonData, backgroundColor: '#04844b', radius:5},
{label: 'Active',data: activeData, backgroundColor: '#ff9a3c', radius:5},
{label: 'Lost',data: lostData, backgroundColor: '#c23934', radius:5},
{label: 'This Deal',data: thisData, backgroundColor: '#0070d2', radius:7}
]
},
options: {
scales: {
xAxes: [{
type: 'linear',
position: 'bottom',
scaleLabel: {display:true, labelString: 'Quantity'}
}],
yAxes: [{
type: 'linear',
position: 'left',
scaleLabel: {display:true, labelString: 'Price'}
}]
},
legend:{
position:'bottom'
}
}
};
var ctx = document.getElementById('chart');
ctx.getContext('2d').canvas.height = ctx.parentElement.offsetHeight;
ctx.getContext('2d').canvas.width = ctx.parentElement.offsetWidth;
var scatterChart = new Chart(ctx, chartParams);
}
/**
Time series as line chart
*/
function processTimeChartData(err, res){
console.debug('*CPQ_PA Processing Time Plot');
if(err){
console.error(err);
return;
}
console.debug('*CPQ_PA Processing Results:');
console.debug(res);
var priceData = [];
for(var i = 0; i< res.records.length; i++){
if(res.records[i].CloseDate){
priceData.push({t: new Date(res.records[i].CloseDate),y:res.records[i].expr0});
}
}
var chartParams = {
type: 'line',
data: {
datasets: [
{label: 'Closed', pointStyle:'circle', data: priceData, borderColor: '#ff9a3c', pointRadius:5, showLine:true}
]
},
options: {
legend:{
position:'bottom'
},
scales: {
xAxes: [
{
type: 'time',
position: 'bottom',
time: {unit: 'month'},
scaleLabel: {display:true, labelString: 'Close Date'}
}
],
yAxes: [
{
type: 'linear',
position: 'left',
scaleLabel: {display:true, labelString: 'Average Selling Price'}
}
]
}
}
};
var ctx = document.getElementById('timechart');
ctx.getContext('2d').canvas.height = ctx.parentElement.offsetHeight;
ctx.getContext('2d').canvas.width = ctx.parentElement.offsetWidth;
var scatterChart = new Chart(ctx, chartParams);
}
var conn = new jsforce.Connection({ accessToken: '{!$API.Session_Id}' });
/**
Examples of accessing historical data client side using jsforce
*/
conn.query('SELECT Id, SBQQ__NetPrice__c, SBQQ__ListPrice__c, SBQQ__Quantity__c FROM SBQQ__QuoteLine__c WHERE SBQQ__Product__c = \'{!productId}\' AND SBQQ__Quote__r.SBQQ__Opportunity2__c !=null AND SBQQ__NetPrice__c != null AND SBQQ__Quantity__c != null AND SBQQ__Quote__r.SBQQ__Primary__c = TRUE AND SBQQ__Quote__r.SBQQ__Opportunity2__r.CloseDate <= TODAY ORDER BY SBQQ__Quote__r.SBQQ__Opportunity2__r.CloseDate DESC LIMIT 200', processChartData);
conn.query('SELECT SBQQ__Quote__r.SBQQ__Opportunity2__r.CloseDate, AVG(SBQQ__NetPrice__c) FROM SBQQ__QuoteLine__c WHERE SBQQ__Product__c = \'{!productId}\' AND SBQQ__Quote__r.SBQQ__Primary__c = TRUE AND SBQQ__Quote__r.SBQQ__Opportunity2__r.CloseDate <= TODAY AND SBQQ__Quote__r.SBQQ__Opportunity2__r.IsWon = TRUE GROUP BY SBQQ__Quote__r.SBQQ__Opportunity2__r.CloseDate ORDER BY SBQQ__Quote__r.SBQQ__Opportunity2__r.CloseDate', processTimeChartData);
</script>
</div>
</body>
</html>
</apex:page>
2. Apex Controller: This is the backend logic of the Analyzer. It pulls data from Salesforce using SOQL queries, processes it, and serves it to the Visualforce page.
The Visualforce page is structured to provide an intuitive user experience, with a clear separation of different analysis sections. The Apex controller is designed with efficiency in mind, ensuring data is retrieved and processed swiftly.
/*
* Copyright (c) 2018 Matthew Seeger <mseeger@salesforce.com>
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/**
* Controller for CPQ Price Analyzer Visualforce Page
* Parses URL parameters and can optionally fetch data for presentation in the Visualforce page
* @author Matthew Seeger <mseeger@salesforce.com>
*/
public class PriceAnalyzerController{
/**
* Salesforce ID of the Product2 record in scope
* Passed as URL parameter in Salesforce CPQ Custom Setting
*/
public Id productId {get; set;}
/**
* Product Object with additional data of the product in scope
*/
public Product2 productObj {get; set;}
/**
* As an example, the Quantity quoted of the item in scope
* Passed as URL parameter in Salesforce CPQ Custom Setting
*/
public Decimal param1 {get; set;}
/**
* As an example, the Sale Price quoted of the item in scope
* Passed as URL parameter in Salesforce CPQ Custom Setting
*/
public Decimal param2 {get; set;}
/**
* As an example, using Apex as the means of data retrival,
* the MAX HIstorical Sale Price quoted of the item in scope
* Set in {@link PriceAnalyzerController#getPricePoints}
*/
public Decimal high {get; set;}
/**
* As an example, using Apex as the means of data retrival,
* the AVERAGE HIstorical Sale Price quoted of the item in scope
* Set in {@link PriceAnalyzerController#getPricePoints}
*/
public Decimal avg {get; set;}
/**
* As an example, using Apex as the means of data retrival,
* the MIN HIstorical Sale Price quoted of the item in scope
* Set in {@link PriceAnalyzerController#getPricePoints}
*/
public Decimal low {get; set;}
/**
* Constructor for the Visualforce page
* Simply parses data in URL and makes it available to the visualforce page
*/
public PriceAnalyzerController(){
this.param1 = Decimal.valueOf(ApexPages.currentPage().getParameters().get('SBQQ__Quantity__c')); //This Parameter is defined the
this.param2 = Decimal.valueOf(ApexPages.currentPage().getParameters().get('SBQQ__NetPrice__c'));
this.productId = ApexPages.currentPage().getParameters().get('SBQQ__Product_c');
this.productObj = [SELECT Name FROM Product2 WHERE Id = :this.productId];
getPricePoints();
}
/**
* Fetches historical MAX, AVERAGE, and MIN Sales price of the item in scope.
* Privided as an example of how to surface data in the visualforce page using SOQL in Apex looking at CPQ Quote Line Records
*/
private void getPricePoints(){
AggregateResult[] res = [SELECT AVG(SBQQ__NetPrice__c), MAX(SBQQ__NetPrice__c), MIN(SBQQ__NetPrice__c) FROM SBQQ__QuoteLine__c WHERE SBQQ__Product__c = :this.productId AND SBQQ__Quote__r.SBQQ__Primary__c = true AND SBQQ__Quote__r.SBQQ__Opportunity2__r.CloseDate <= TODAY ];
this.avg = (Decimal)res[0].get('expr0');
this.high = (Decimal)res[0].get('expr1');
this.low = (Decimal)res[0].get('expr2');
}
}
Conclusion
The Line Editor Price Analyzer stands as a testament to the flexibility and power of Salesforce CPQ. By offering in-depth, real-time pricing analysis, it equips sales teams with the intelligence needed to close deals with confidence. As businesses continue to seek out competitive advantages, tools like the Price Analyzer will play a pivotal role in shaping the future of CPQ.
- Access the source code on GitHub: https://github.com/mattseeger/salesforce-cpq-line-analyzer
Whether you're a seasoned Salesforce CPQ user or new to the platform, exploring the Line Editor Price Analyzer could be the next step in optimizing your sales process and pricing strategy.
Comments