I've got a class which uses a wrapper class to handle OpportunityLineItem
's which both have ids and don't have an id. Before calling an upsert
on these records to update them, I extract the records from the wrappers, then use those records.
This works perfectly, except if someone tries to set the Quantity
or UnitPrice
to 0. I think the field itself rejects these changes, so I added a validation rule over the fields to try to help with this. When this rule fails, the system throws a DMLException, as expected. This DML Exception specifies the real cause of the problem:
FIELD_INTEGRITY_EXCEPTION, field integrity exception: TotalPrice
This however, is not the error presented to the end user. Instead, they get:
FATAL_ERROR|System.StringException: Invalid id: ProductManager.UpdateAll();
What happens between these two errors? SLEEPY
. The system tries to update a list of records, which looks like:
{call sOppLineItem.update_opplineitems(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)}
Which clearly fails. Then this message:
15:46:29.0 (124359697)|VF_PAGE_MESSAGE|common.exception.SfdcSqlException: ORA-20067: OPPORTUNITY_LINEITEM.QUANTITY
ORA-06512: at "SLEEPY.SOPPLINEITEM", line 1203
ORA-06512: at line 1
Instead of a validation rules highlight, they get a broken page. I can replicate this in a sandbox, but I don't have a mention of SLEEPY
in that log.
Heres a repo class & page. You'll need to create a project, add a few products, and then change the preset id (i know, its a demo) to your project.
public class ProductManager_demo {
private Id projectId {
get {
return Project == null ? '' : Project.Id;
}
}
public String projectName {
get {
return Project == null ? '' : Project.Name;
}
}
public Opportunity Project { get; set; }
@testVisible
private Map<Integer, ProductWrapper> ProductWrapperMap { get; set; }
// Used for DeleteSelected and UpdateTotals
public Integer SelectedProductId { get; set; }
public ProductManager_demo() {
//if (Utility.IsBlank(ApexPages.currentPage().getParameters().get('id'))) {
//ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, 'There must be an Id specified in the url parameters. Please contact your administrator.'));
//} else {
Project = [
SELECT Id, Name, Amount
FROM Opportunity
WHERE Id = '006c000000IE6RI' // :ApexPages.currentPage().getParameters().get('id')
LIMIT 1
];
//}
}
public List<ProductWrapper> getProducts() {
if (ProductWrapperMap == null) {
ProductWrapperMap = new Map<Integer, ProductWrapper>();
ProductWrapperMap.putAll(ConvertToWrapper(QueryLineItems()));
}
return ProductWrapperMap.values();
}
// Replace w/ field set ?
private List<OpportunityLineItem> QueryLineItems() {
return [
SELECT Id, Description, Quantity, UnitPrice, ServiceDate
FROM OpportunityLineItem
WHERE OpportunityId = :projectId
];
}
public void DeleteSelected() {
DeleteSelected_future(ProductWrapperMap.get(SelectedProductId).Record.Id); // may return null - need to test
ProductWrapperMap.remove(SelectedProductId);
}
// Need to check invoice amount before rendering delete link & in product trigger
//
// Uses @future to quickly remove product from page, and then very shortly after, delete the product
@future
public static void DeleteSelected_future(Id id) {
if (Utility.IsNotBlank(id)) {
try {
delete [SELECT Id FROM OpportunityLineItem WHERE Id = :id];
} catch (Exception e) {
ApexPages.addMessages(e);
ErrorLogger.LogApexError(e, '@future ProductManager.Delete();', id);
}
}
}
// Will likely remove
public PageReference DeleteAll() {
try {
delete ExtractValidValuesFromWrapperMap(ProductWrapperMap);
ProductWrapperMap = null;
return Back();
} catch (Exception e) {
ApexPages.addMessages(e);
ErrorLogger.LogApexError(e, 'ProductManager.DeleteAll();', project.Id);
}
return null;
}
public PageReference UpdateAll() {
try {
upsert ExtractValuesFromWrapperMap(ProductWrapperMap);
return Back();
} catch (Exception e) {
ApexPages.addMessages(e);
ErrorLogger.LogApexError(e, 'ProductManager.UpdateAll();', project.Id);
}
return null;
}
public PageReference Back() {
return new PageReference('https://' + System.URL.getSalesforceBaseUrl().getHost() + '/' + projectId);
}
// Below methods are for interacting with ProductWrappers - could likely be moved somewhere else
// Unique per record Hash (Could also use System.HashCode())
private static Integer someHash = 0;
private static Integer NextHash() {
someHash ++;
return someHash;
}
private Map<Integer, ProductWrapper> ConvertToWrapper(List<OpportunityLineItem> items) {
Map<Integer, ProductWrapper> productMap = new Map<Integer, ProductWrapper>();
for (OpportunityLineItem item:items) {
ProductWrapper wrapper = new ProductWrapper(item);
productMap.put(wrapper.Hash, wrapper);
}
return productMap;
}
// Problem is likely with data returned from here
// Data isn't the same as on page - problem? Cant connect back to wrapper to show validation error on page?
private List<OpportunityLineItem> ExtractValidValuesFromWrapperMap(Map<Integer, ProductWrapper> wrapperList) {
List<OpportunityLineItem> items = new List<OpportunityLineItem>();
for (ProductWrapper wrapper:wrapperList.values()) {
if (Utility.IsNotBlank(wrapper.Record.Id)) {
items.add(wrapper.Record);
}
}
return items;
}
private List<OpportunityLineItem> ExtractValuesFromWrapperMap(Map<Integer, ProductWrapper> wrapperList) {
List<OpportunityLineItem> items = new List<OpportunityLineItem>();
for (ProductWrapper wrapper:wrapperList.values()) {
items.add(wrapper.Record);
}
return items;
}
@testVisible
private class ProductWrapper {
public Integer Hash {
get {
if (Hash == null) {
Hash = ProductManager_demo.NextHash();
}
return Hash;
}
set;
}
private PriceBookEntry Entry {
get;
set;
}
public String Name {
get {
if (Entry != null && Utility.IsNotBlank(Entry.Name)) {
return Entry.Name;
} else {
return '';
}
}
}
public Double ListPrice {
get {
if (Entry != null && Entry.UnitPrice != null) {
return Double.ValueOf(Entry.UnitPrice);
} else {
return 0;
}
}
}
public OpportunityLineItem Record { get; set; }
public ProductWrapper(Id projectId, PriceBookEntry entry) {
Record = new OpportunityLineItem(
OpportunityId= projectId,
PricebookEntryId= entry.Id,
UnitPrice= entry.UnitPrice,
Quantity= 1
);
this.Entry = entry; // Stored in own record to prevent recalculateFormulas error
}
public ProductWrapper(OpportunityLineItem lineItem) {
this.Record = lineItem;
}
}
}
Page
<apex:page controller="ProductManager_demo" tabstyle="Opportunity" title="Manage Products for: {!projectName}" sidebar="false" >
<apex:sectionHeader title="Manage Products" subtitle="Manage Products on {!projectName}"/>
<apex:outputPanel rendered="true" id="componentBody">
<apex:form>
<apex:pageBlock title="Manage Products for: {!projectName}">
<apex:pageBlockButtons location="top">
<div id="top-button-stop">
<apex:commandButton value="Delete All" immediate="true" action="{!DeleteAll}" rerender="table_panel" rendered="{!IF((products.size > 0), true, false)}" style="float: right; margin-bottom: 7px;" />
</div>
<div id="top-button-start">
<apex:commandButton value="Saving..." disabled="true" style="float: right; margin-bottom: 7px;" />
</div>
</apex:pageBlockButtons>
<apex:pageBlockButtons location="bottom" rendered="{!IF((products.size > 0), true, false)}">
<apex:actionStatus id="SavingStatus">
<apex:facet name="stop">
<apex:outputPanel id="stop_panel">
<apex:commandButton value="Save" action="{!UpdateAll}" status="SavingStatus" rerender="table_panel,stop_panel" />
<apex:commandButton value="Cancel" immediate="true" status="SavingStatus" action="{!Back}" />
</apex:outputPanel>
</apex:facet>
<apex:facet name="start">
<apex:outputPanel >
<apex:commandButton value="Saving..." disabled="true" status="SavingStatus" />
<apex:commandButton value="Saving..." disabled="true" status="SavingStatus" />
</apex:outputPanel>
</apex:facet>
</apex:actionStatus>
</apex:pageBlockButtons>
<apex:outputPanel rendered="{!IF((products.size > 0), true, false)}">
<apex:outputPanel id="table_panel" rendered="true" layout="block">
<apex:pageMessages />
<apex:pageBlockTable value="{!products}" var="wrapper">
<apex:column value="{!wrapper.Hash}" />
<apex:column headerValue="Product Name" width="600px">
<apex:outputText value="{!wrapper.Name}" />
<apex:outputPanel rendered="{!ISBLANK(wrapper.Record.Id)}"><span style="color: red;"> (Unsaved)</span></apex:outputPanel>
</apex:column>
<apex:column headervalue="Number of Units">
<apex:inputField value="{!wrapper.Record['Quantity']}" onblur="updateTotals('Quantity', this.value, '{!wrapper.Hash}');" />
</apex:column>
<apex:column headervalue="List Price">
<apex:outputText value="{0, Number, Currency}">
<apex:param value="{!wrapper.ListPrice}" />
</apex:outputText>
</apex:column>
<apex:column headervalue="Sales Price">
<apex:inputField value="{!wrapper.Record['UnitPrice']}" required="true" onblur="updateTotals('UnitPrice', this.value, '{!wrapper.Hash}');" />
</apex:column>
<apex:column headervalue="Date">
<apex:inputField value="{!wrapper.Record['ServiceDate']}" />
</apex:column>
<apex:column headervalue="Line Description">
<apex:inputField value="{!wrapper.Record['Description']}" />
</apex:column>
<apex:column headervalue="Remove Product">
<apex:commandLink action="{!DeleteSelected}" value="Delete" rerender="table_panel">
<apex:param name="SelectedProductId" value="{!wrapper.Hash}" assignTo="{!SelectedProductId}" />
</apex:commandLink>
</apex:column>
</apex:pageBlockTable>
</apex:outputPanel>
</apex:outputPanel>
<apex:outputPanel rendered="{!IF((products.size <= 0), true, false)}">
<apex:pageMessages />
<div style="margin-left: 31%; margin-top: 20px;">
No records to display.
</div>
</apex:outputPanel>
</apex:pageBlock>
</apex:form>
</apex:outputPanel>
</apex:page>
I'm pretty sure the problem is with the objects returned from the wrapper class. These are technically the same records whos fields are displayed on the layout, but I'm wondering if the wrapper is enough to much up salesforce.
I'm also sure I'll need to contact salesforce support over this one, but, well, I honestly don't want to if I don't have too, I'm expecting to be bumped to the developer forums since I don't have developer support (Unless platform bugs like this get priority). Hopefully someone whos seen this before can chime in with something I can try. I'll be trying a few things on my own over the next 24 hours, such as dropping the wrapper class and other error handling patterns (I just need the right exception to show on the page).
VF_PAGE_MESSAGE
may be a harder sell