/*********************************************************************************************************** * Name : CA_ServiceTrackerHelper * Purpose : Helper methods for CA Service Tracker Functionality **********************************************************************************************************/ public with sharing class CA_ServiceTrackerHelper { public static List RIGHT_FIELDS; public static final String BASE_QUERY = 'SELECT' + ' Master_Right__r.Name,' + ' Master_Right__r.Icon__c,' + ' Master_Right__r.Background_Color__c,' + ' Master_Right__r.Approver__c,' + ' Master_Right__r.Approver__r.Name,' + ' Master_Right__r.HelpText__c,' + ' Master_Right__r.Region__c, ' + ' Master_Right__r.Availability__c, ' + ' Master_Right__r.Availability_Type__c, ' + ' Master_Right__r.Order__c, '; public static final String STATUS_APPROVED = 'Approved'; public static final String STATUS_AUTOAPPROVED = 'Auto-Approved'; public static final String STATUS_PENDING_APPROVAL = 'Pending Approval'; public static final String STATUS_REJECTED = 'Rejected'; public static final String STATUS_ACTION_REQUIRED = 'Action Required'; public static final List COMMON_FIELDS = new List{ 'Service_Requested__c', 'Reason_Right_Not_Acquired__c', 'Please_Expand__c', 'When_Is_Right_Available__c', 'Notes__c', 'Approval_Status__c', 'Resubmit__c', 'Bypass_Approval__c' }; public static final List OPP_FIELDS_FOR_DASHBOARD = new List{ 'Opportunity__r.Id', 'Opportunity__r.Name', 'Opportunity__r.DealTypeBucket__c', 'Opportunity__r.CA_Closer__c', 'Opportunity__r.CA_Total_Advance_Amount__c', 'Opportunity__r.CA_Currency__c', 'Opportunity__r.TermLengthInYears__c', 'Opportunity__r.AdditionalTermLengthInYears__c', 'Opportunity__r.CA_Digital_Distribution_fee__c' }; public static final String DATETIME_SAVE_FORMAT = 'yyyy-MM-dd HH:mm:ss'; public static final String DATETIME_LOAD_FORMAT = 'yyyy-MM-dd'; public static final String ROYALTY_SHARE_SERVICE = 'RoyaltyShare'; public static final String ST_REPORTS_FOLDER = 'CA Service Tracker Reports'; public static final String ST_HUB_URL = '/lightning/n/Service_Tracker_Hub'; /** * Retrieves all pending approval services for a specific Opportunity to display in a banner. * * @param oppId The ID of the Opportunity to retrieve pending services for * @return List List of services pending approval */ @AuraEnabled public static List getPendingServicesForBanner(String oppId) { return [ SELECT Id, Master_Right__r.Name, Master_Right__r.Availability__c, Master_Right__r.Availability_Type__c FROM Opportunity_Right__c WHERE Opportunity__c = :oppId AND Approval_Status__c = :STATUS_PENDING_APPROVAL ]; } /** * Retrieves all fields from the Opportunity_Right__c object using lazy-loading pattern. * Caches the field list in the RIGHT_FIELDS static variable to optimize repeated calls. * * @return List List of all API field names from Opportunity_Right__c */ public static List getRightFields() { if (RIGHT_FIELDS == null) { RIGHT_FIELDS = SchemaUtils.getAllFields('Opportunity_Right__c'); } return RIGHT_FIELDS; } /** * Retrieves field metadata for the Service Tracker UI organized by Master Right. * Queries Service_Tracker_Setup__c records and fetches field descriptions from both * Opportunity and Opportunity_Right__c objects. Results are cached for performance. * * @return Map> Map keyed by Master Right name with configured field metadata, * or null on error */ @AuraEnabled(cacheable=true) public static Map> getFieldsFromSetup() { try { Map> fieldsMap = SchemaUtils.getAllFieldsDescribeBySObjectNames( new List{ 'Opportunity_Right__c', 'Opportunity' } ); Map serviceFieldsMap = fieldsMap.get('Opportunity_Right__c'); Map oppFieldsMap = fieldsMap.get('Opportunity'); Map> result = new Map>(); for (Service_Tracker_Setup__c setupRecord : [ SELECT Id, Master_Right__c, Master_Right__r.Name, Master_Right__r.Order__c, Order__c, Field_Name__c, Opp_Field__c, Territory_Field__c, Dependant_Field__c, Dependant_Value__c, Help_Text__c FROM Service_Tracker_Setup__c WHERE Master_Right__r.Active__c = TRUE ORDER BY Master_Right__r.Order__c, Order__c ]) { if (!result.containsKey(setupRecord.Master_Right__r.Name)) { result.put(setupRecord.Master_Right__r.Name, new List()); } if (setupRecord.Opp_Field__c) { result.get(setupRecord.Master_Right__r.Name) .add( buildServiceTrackerSetupFieldWrapper( oppFieldsMap.get(setupRecord.Field_Name__c), setupRecord ) ); } else { result.get(setupRecord.Master_Right__r.Name) .add( buildServiceTrackerSetupFieldWrapper( serviceFieldsMap.get(setupRecord.Field_Name__c), setupRecord ) ); } } return result; } catch (Exception ex) { ErrorLogger.logErrorFuture(ex); return null; } } /** * Retrieves all active services (Opportunity Rights) associated with an Account from completed Opportunities. * Returns only the most recent service record for each Master Right by filtering opportunities with 'Complete' stage. * Includes related Acquired Opportunity details and all configured Opportunity_Right__c fields. * Deduplicates results to ensure one service per Master Right. * * @param accountId The ID of the Account to retrieve services for * @return List List of the most recent active services, deduplicated by Master Right, * or null if an error occurs * @throws AuraHandledException if there's an error in service retrieval or database query execution */ @AuraEnabled public static List getServicesByAccount(String accountId) { try { String query = BASE_QUERY + ' Acquired_Opportunity__r.Name, ' + ' Acquired_Opportunity__r.Contract_Executed_by_Orchard__c, ' + String.join(getRightFields(), ',') + ' FROM Opportunity_Right__c' + ' WHERE Account_Id__c = :accountId' + ' AND Master_Right__r.Active__c = TRUE' + ' AND Opportunity__r.StageName = \'' + CA_Constants.OPPORTUNITY_STAGE_COMPLETE + '\'' + ' ORDER BY Master_Right__r.Order__c, Opportunity__r.LastStageChangeDate DESC NULLS LAST'; List services = Database.query(query); if (!services.isEmpty()) { Map serviceMap = new Map(); for (Opportunity_Right__c service : services) { if (!serviceMap.containsKey(service.Master_Right__c)) { serviceMap.put(service.Master_Right__c, service); } } services = serviceMap.values(); } return services; } catch (Exception ex) { ErrorLogger.logError(ex); return null; } } /** * Retrieves or creates service records (Opportunity Rights) for a specific Opportunity. * If existing services are found for the Opportunity, returns them with all configured fields. * If no services exist, creates new in-memory service records for all active Master Rights with default values * (Service_Requested__c = false, Approval_Status__c = null). Created records are not persisted to the database. * * @param oppId The ID of the Opportunity to retrieve or create services for * @return List List of existing services from database or newly created in-memory services for all active Master Rights, * or null if an error occurs * @throws AuraHandledException if there's an error in service retrieval or creation */ @AuraEnabled public static List getServicesByOpportunity(String oppId) { try { String query = BASE_QUERY + ' Acquired_Opportunity__r.Name, ' + String.join(getRightFields(), ',') + ' FROM Opportunity_Right__c' + ' WHERE Opportunity__c = :oppId' + ' AND Master_Right__r.Active__c = TRUE' + ' ORDER BY Master_Right__r.Order__c'; List services = Database.query(query); if (services.isEmpty()) { List masterRights = [ SELECT Id, Name, Icon__c, Background_Color__c, Approver__c, Approver__r.Name, Region__c, HelpText__c, Availability__c, Availability_Type__c, Order__c FROM Master_Right__c WHERE Active__c = TRUE ORDER BY Order__c ]; for (Master_Right__c masterRight : masterRights) { Opportunity_Right__c newService = new Opportunity_Right__c( Master_Right__c = masterRight.Id, Master_Right__r = masterRight, Opportunity__c = oppId, Approval_Status__c = null, Service_Requested__c = false ); services.add(newService); } } else { formatApprovalHistoryForUi(services); } return services; } catch (Exception ex) { ErrorLogger.logError(ex); return null; } } /** * Retrieves all pending approval services for the current user based on their approver role. * Dynamically includes configured Opportunity fields from Service Tracker Setup for display. * Returns services that are awaiting approval from the logged-in user. * * @return List List of services pending approval for the current user */ @AuraEnabled public static List getServicesByApprover() { String userId = UserInfo.getUserId(); Set oppFields = new Set(); for (Service_Tracker_Setup__c record : [ SELECT Field_Name__c FROM Service_Tracker_Setup__c WHERE Opp_Field__c = TRUE AND Master_Right__r.Active__c = TRUE ]) { oppFields.add('Opportunity__r.' + record.Field_Name__c); } oppFields.addAll(OPP_FIELDS_FOR_DASHBOARD); String additionalOppFields = String.join(oppFields, ',') + ','; String query = BASE_QUERY + additionalOppFields + String.join(getRightFields(), ',') + ' FROM Opportunity_Right__c' + ' WHERE Master_Right__r.Approver__c = :userId' + ' AND Approval_Status__c = :STATUS_PENDING_APPROVAL' + ' AND Master_Right__r.Active__c = TRUE' + ' ORDER BY Master_Right__r.Order__c'; List services = Database.query(query); formatApprovalHistoryForUi(services); return services; } /** * Refreshes Service Tracker records for existing CA Opportunities. * * For each target Opportunity: * 1. Finds the latest completed Opportunity rights by Account (one record per Master Right) * 2. Rebuilds Opportunity_Right__c records using that baseline * 3. Adds missing Master Rights with default values * * @param opportunityIds List of Opportunity Ids to refresh * @param masterRights Required list of Master Right records to refresh */ public static void refreshServicesForOpportunities(List opportunityIds, List masterRights) { try { if (masterRights == null || masterRights.isEmpty()) { throw new IllegalArgumentException('masterRights must be non-empty.'); } Set masterRightsToRefreshIds = new Set(); Boolean shouldRefreshRoyaltyShare = false; for (Master_Right__c masterRight : masterRights) { masterRightsToRefreshIds.add(masterRight.Id); if (masterRight.Name == ROYALTY_SHARE_SERVICE) { shouldRefreshRoyaltyShare = true; } } Set caRecordTypeIds = new Set(OpportunityTriggerHelper.CA_RECORD_TYPES_MAP.values()); Map> oppsByAccount = new Map>(); Set accountIds = new Set(); Set targetOppIds = new Set(); for (Opportunity opp : [ SELECT Id, AccountId, RecordTypeId, StageName, CA_RoyaltyShare__c FROM Opportunity WHERE Id IN :opportunityIds ]) { if ( opp.AccountId == null || !caRecordTypeIds.contains(opp.RecordTypeId) || opp.StageName == CA_Constants.OPPORTUNITY_STAGE_COMPLETE ) { continue; } if (!oppsByAccount.containsKey(opp.AccountId)) { oppsByAccount.put(opp.AccountId, new List()); } oppsByAccount.get(opp.AccountId).add(opp); accountIds.add(opp.AccountId); targetOppIds.add(opp.Id); } if (targetOppIds.isEmpty()) { return; } List existingTargetServices = [ SELECT Id FROM Opportunity_Right__c WHERE Opportunity__c IN :targetOppIds AND Master_Right__c IN :masterRightsToRefreshIds ]; String historicalServicesQuery = 'SELECT ' + ' Master_Right__r.Name, ' + String.join(getRightFields(), ',') + ' FROM Opportunity_Right__c' + ' WHERE Account_Id__c IN :accountIds' + ' AND Master_Right__r.Active__c = TRUE' + ' AND Master_Right__c IN :masterRightsToRefreshIds' + ' AND Opportunity__c NOT IN :targetOppIds' + ' AND Opportunity__r.StageName = \'' + CA_Constants.OPPORTUNITY_STAGE_COMPLETE + '\'' + ' ORDER BY Opportunity__r.LastStageChangeDate DESC NULLS LAST'; Map> baselineByAccount = new Map>(); for (Opportunity_Right__c service : Database.query(historicalServicesQuery)) { Map baselineByMasterRight = baselineByAccount.get(service.Account_Id__c); if (baselineByMasterRight == null) { baselineByMasterRight = new Map(); baselineByAccount.put(service.Account_Id__c, baselineByMasterRight); } if (!baselineByMasterRight.containsKey(service.Master_Right__c)) { baselineByMasterRight.put(service.Master_Right__c, service); } } List servicesToInsert = new List(); List oppsToUpdate = new List(); for (Id accountId : oppsByAccount.keySet()) { Map baselineByMasterRight = baselineByAccount.containsKey(accountId) ? baselineByAccount.get(accountId) : new Map(); for (Opportunity opp : oppsByAccount.get(accountId)) { Boolean hasAcquiredRoyaltyShare = false; for (Opportunity_Right__c baselineService : baselineByMasterRight.values()) { Opportunity_Right__c newService = baselineService.clone(false, true, false, false); newService.Opportunity__c = opp.Id; newService.Approval_History__c = null; newService.Approval_Snapshot__c = null; servicesToInsert.add(newService); if ( shouldRefreshRoyaltyShare && baselineService.Master_Right__r.Name == ROYALTY_SHARE_SERVICE && baselineService.Acquired__c ) { hasAcquiredRoyaltyShare = true; } } for (Master_Right__c masterRight : masterRights) { if (masterRight == null || masterRight.Id == null) { continue; } if (!baselineByMasterRight.containsKey(masterRight.Id)) { servicesToInsert.add( new Opportunity_Right__c( Opportunity__c = opp.Id, Master_Right__c = masterRight.Id, Service_Requested__c = false, Approval_Status__c = null ) ); } } if (shouldRefreshRoyaltyShare && opp.CA_RoyaltyShare__c != hasAcquiredRoyaltyShare) { oppsToUpdate.add(new Opportunity(Id = opp.Id, CA_RoyaltyShare__c = hasAcquiredRoyaltyShare)); } } } if (!existingTargetServices.isEmpty()) { delete existingTargetServices; } if (!servicesToInsert.isEmpty()) { insert servicesToInsert; } if (!oppsToUpdate.isEmpty()) { TriggerControl.disableTrigger(OpportunityTriggerHandler.SELF); update oppsToUpdate; TriggerControl.enableTrigger(OpportunityTriggerHandler.SELF); } } catch (Exception ex) { ErrorLogger.logError(ex); throw ex; } } /** * Submits approval decisions from the Service Tracker Dashboard. * Processes approval/rejection decisions and refreshes the pending approval list for the current approver. * * @param services List of Opportunity_Right__c records with approval decisions * @return List Updated list of remaining pending approval services for the current user */ @AuraEnabled public static List submitApprovalDecisionFromDashboard(List services) { List result = submitApprovalDecision(services); if (result != null) { result = getServicesByApprover(); } return result; } /** * Submits approval decisions for a list of services and records the decision metadata and approval snapshots. * Processes both approved and rejected decisions, capturing the decision time, approver name, and a snapshot * of all configured fields at the time of decision. For approved services, links the Acquired_Opportunity__c * field to the source Opportunity. For rejected services, clears the Acquired_Opportunity__c field. * Services with no decision (neither approved nor rejected) have their approval-related fields cleared. * * @param services List of Opportunity_Right__c records with approval decisions set to STATUS_APPROVED, STATUS_REJECTED, or null * @return List The updated and upserted services, or null if an exception occurs * @throws AuraHandledException if there's an error during field metadata retrieval, snapshot generation, or database upsert */ @AuraEnabled public static List submitApprovalDecision(List services) { try { Map serviceFieldsMap; Map oppFieldsMap; Map> masterRightSetupRecordsMap; Map oppsMap = new Map(); Set oppFields = new Set(); Map> fieldsMap = SchemaUtils.getAllFieldsDescribeBySObjectNames( new List{ 'Opportunity_Right__c', 'Opportunity' } ); serviceFieldsMap = fieldsMap.get('Opportunity_Right__c'); oppFieldsMap = fieldsMap.get('Opportunity'); Set masterRights = new Set(); for (Opportunity_Right__c service : services) { masterRights.add(service.Master_Right__c); } masterRightSetupRecordsMap = new Map>(); for (Service_Tracker_Setup__c setupRecord : [ SELECT Field_Name__c, Opp_Field__c, Master_Right__c FROM Service_Tracker_Setup__c WHERE Master_Right__c = :masterRights ORDER BY Order__c ]) { if (!masterRightSetupRecordsMap.containsKey(setupRecord.Master_Right__c)) { masterRightSetupRecordsMap.put(setupRecord.Master_Right__c, new List()); } masterRightSetupRecordsMap.get(setupRecord.Master_Right__c).add(setupRecord); if (setupRecord.Opp_Field__c) { oppFields.add('Opportunity__r.' + setupRecord.Field_Name__c); } } String oppFieldsForQuery = oppFields.isEmpty() ? '' : (String.join(oppFields, ',') + ','); String query = 'SELECT' + ' Master_Right__r.Name,' + ' Opportunity__r.CA_RoyaltyShare__c,' + oppFieldsForQuery + String.join(serviceFieldsMap.keySet(), ',') + ' FROM Opportunity_Right__c' + ' WHERE Id = :services' + ' AND Master_Right__r.Active__c = TRUE' + ' ORDER BY Master_Right__r.Order__c'; Map oldServices = new Map( (List) Database.query(query) ); Map oppsToUpdate = new Map(); for (Opportunity_Right__c service : services) { if ( service.Approval_Status__c == STATUS_APPROVED || service.Approval_Status__c == STATUS_REJECTED || service.Approval_Status__c == STATUS_ACTION_REQUIRED ) { service.Decision_Time__c = System.now(); service.Approver_Name__c = UserInfo.getName(); if (service.Approval_Status__c != STATUS_ACTION_REQUIRED) { service.Approval_Snapshot__c = buildSnapshot( serviceFieldsMap, oppFieldsMap, masterRightSetupRecordsMap.get(service.Master_Right__c), service, oldServices.get(service.Id) ); } if (service.Approval_Status__c == STATUS_APPROVED) { service.Acquired_Opportunity__c = service.Opportunity__c; } else { service.Acquired_Opportunity__c = null; } if ( service.Approval_Status__c == STATUS_APPROVED && service.Approval_Status__c != oldServices.get(service.Id).Approval_Status__c && oldServices.get(service.Id).Master_Right__r.Name == ROYALTY_SHARE_SERVICE && !oldServices.get(service.Id).Opportunity__r.CA_RoyaltyShare__c ) { oppsToUpdate.put( service.Opportunity__c, new Opportunity(Id = service.Opportunity__c, CA_RoyaltyShare__c = true) ); } } else { service.Decision_Time__c = null; service.Approver_Name__c = null; service.Approval_Snapshot__c = null; service.Acquired_Opportunity__c = null; service.Approver_Notes__c = null; } formatApprovalHistoryForDb(service, oldServices.get(service.Id)); service.Resubmit__c = false; } upsert services; if (!oppsToUpdate.isEmpty()) { TriggerControl.disableTrigger(OpportunityTriggerHandler.SELF); update oppsToUpdate.values(); TriggerControl.enableTrigger(OpportunityTriggerHandler.SELF); } sendApprovalDecisionEmails(services, oldServices); formatApprovalHistoryForUi(services); return services; } catch (Exception ex) { ErrorLogger.logError(ex); return null; } } /** * Sends email notifications to the Opportunity owner when an Opportunity_Right__c * record changes its Approval_Status__c to one of thestatuses "Approved", "Rejected", * or "Action Required". * @param updatedServices The list of Opportunity_Right__c records after update (new values). * @param oldServicesMap Map of Opportunity_Right__c Id to the pre-update version of the record (old values). */ private static void sendApprovalDecisionEmails( List updatedServices, Map oldServicesMap ) { try { List servicesToNotify = new List(); Set oppIdsToQuery = new Set(); Set statusesToNotify = new Set{ STATUS_APPROVED, STATUS_REJECTED, STATUS_ACTION_REQUIRED }; for (Opportunity_Right__c service : updatedServices) { String oldStatus = oldServicesMap.get(service.Id).Approval_Status__c; if (statusesToNotify.contains(service.Approval_Status__c) && service.Approval_Status__c != oldStatus) { servicesToNotify.add(service); oppIdsToQuery.add(service.Opportunity__c); } } if (servicesToNotify.isEmpty()) { return; } EmailTemplate template = [ SELECT Id, Subject, HtmlValue FROM EmailTemplate WHERE DeveloperName = 'CA_ServiceApprovalDecisionNotification' LIMIT 1 ]; Map oppsMap = new Map( [ SELECT Id, Name, OwnerId, Owner.FirstName, Owner.Email FROM Opportunity WHERE Id IN :oppIdsToQuery ] ); String orgBaseUrl = URL.getOrgDomainURL().toExternalForm(); List emails = new List(); for (Opportunity_Right__c service : servicesToNotify) { Opportunity opp = oppsMap.get(service.Opportunity__c); String serviceName = service.Master_Right__r.Name; String oppName = opp.Name; String status = service.Approval_Status__c; String ownerFirstName = opp.Owner.FirstName; String oppUrl = orgBaseUrl + '/' + service.Opportunity__c; String oppHyperlink = 'Opportunity's Service Tracker'; String decisionMessageLine; String resubmitLine; if (status == STATUS_APPROVED) { decisionMessageLine = 'Your request for ' + serviceName + ' has been approved.'; resubmitLine = 'You are welcomed to resubmit approval for this service via your ' + oppHyperlink + ' if you need new terms approved.'; } else if (status == STATUS_REJECTED) { decisionMessageLine = 'Your request for ' + serviceName + ' has been rejected.'; resubmitLine = 'You are welcomed to resubmit approval for this service via your ' + oppHyperlink + '.'; } else { decisionMessageLine = 'Action is required for the approval of ' + serviceName + ''; resubmitLine = 'You are welcomed to resubmit approval for this service via your ' + oppHyperlink + '.'; } String approverNotes = ''; if (String.isNotBlank(service.Approver_Notes__c)) { approverNotes = 'Approver Notes:
' + '' + service.Approver_Notes__c.trim() + '

'; } Map replaceMap = new Map{ '{{RIGHTNAME}}' => serviceName, '{{OPPORTUNITYNAME}}' => oppName, '{{DECISIONSTATUS}}' => status, '{{OWNERFIRSTNAME}}' => ownerFirstName, '{{DECISIONMESSAGELINE}}' => decisionMessageLine, '{{APPROVERNOTES}}' => approverNotes, '{{RESUBMITLINE}}' => resubmitLine }; String subject = template.Subject; String htmlBody = template.HtmlValue; for (String key : replaceMap.keySet()) { String value = replaceMap.get(key); subject = subject.replace(key, value); htmlBody = htmlBody.replace(key, value); } Messaging.SingleEmailMessage msg = new Messaging.SingleEmailMessage(); msg.setTargetObjectId(opp.OwnerId); msg.setSaveAsActivity(false); msg.setSubject(subject); msg.setHtmlBody(htmlBody); emails.add(msg); } if (!emails.isEmpty()) { Messaging.sendEmail(emails, false); } } catch (Exception ex) { ErrorLogger.logError(ex); } } /** * Builds a snapshot of relevant field values from an Opportunity and its associated Opportunity Right. * Creates a formatted string representation of configured fields for the approval snapshot record. * Used to capture the state of fields at the time of approval decision. * * @param serviceFieldsMap Map of Opportunity_Right__c field metadata keyed by API name * @param oppFieldsMap Map of Opportunity field metadata keyed by API name * @param setupRecords List of Service_Tracker_Setup__c records defining which fields to include * @param opp The Opportunity record to extract field values from * @param service The Opportunity_Right__c record to extract field values from * @return String A formatted snapshot of all configured field values */ private static String buildSnapshot( Map serviceFieldsMap, Map oppFieldsMap, List setupRecords, //Opportunity opp, Opportunity_Right__c service, Opportunity_Right__c oldService ) { List snapshot = new List(); for (Service_Tracker_Setup__c setupRecord : setupRecords) { String fieldName = setupRecord.Field_Name__c; Object fieldValue; if (setupRecord.Opp_Field__c) { fieldValue = oldService.getSObject('Opportunity__r').get(fieldName); } else { fieldValue = service.get(fieldName); } String fieldLabel = setupRecord.Opp_Field__c ? oppFieldsMap.get(fieldName).getLabel() : serviceFieldsMap.get(fieldName).getLabel(); snapshot.add(fieldLabel + ':\n' + (fieldValue ?? '') + '\n'); } return String.join(snapshot, '\n---------------\n'); } /** * Appends a line to Approval_History__c describing changes to a service's request or approval status. * Uses GMT timestamp, current user name, and optional approver notes to build a CSV-style entry. * Skips writing if no relevant change is detected. * * @param newService The new Opportunity_Right__c record after change * @param oldService The previous Opportunity_Right__c record (may be null) */ @TestVisible private static void formatApprovalHistoryForDb(Opportunity_Right__c newService, Opportunity_Right__c oldService) { String oldApprovalHistory = oldService?.Approval_History__c; String userName = UserInfo.getName(); String now = System.now().formatGMT(DATETIME_SAVE_FORMAT); String newLine; if ( (oldService == null || oldService.Service_Requested__c == false) && newService.Service_Requested__c == true ) { if (newService.Bypass_Approval__c) { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',requested the service as previously approved'; } else { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',requested the service'; } } else if ( (oldService != null && oldService.Service_Requested__c == true) && newService.Service_Requested__c == false ) { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',cancelled the request'; } else if ( (oldService == null || (oldService != null && oldService.Approval_Status__c != STATUS_APPROVED)) && newService.Approval_Status__c == STATUS_APPROVED ) { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',approved the request'; if (String.isNotBlank(newService.Approver_Notes__c)) { newLine += ' (' + newService.Approver_Notes__c.escapeCsv().replaceAll('\r\n|\n|\r', ' ').replace(' ', ' ') + ')'; } } else if ( (oldService == null || (oldService != null && oldService.Approval_Status__c != STATUS_REJECTED)) && newService.Approval_Status__c == STATUS_REJECTED ) { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',rejected the request'; if (String.isNotBlank(newService.Approver_Notes__c)) { newLine += ' (' + newService.Approver_Notes__c.escapeCsv().replaceAll('\r\n|\n|\r', ' ').replace(' ', ' ') + ')'; } } else if ( (oldService == null || (oldService != null && oldService.Approval_Status__c != STATUS_ACTION_REQUIRED)) && newService.Approval_Status__c == STATUS_ACTION_REQUIRED ) { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',requested action'; if (String.isNotBlank(newService.Approver_Notes__c)) { newLine += ' (' + newService.Approver_Notes__c.escapeCsv().replaceAll('\r\n|\n|\r', ' ').replace(' ', ' ') + ')'; } } else if ( (oldService != null && oldService.Approval_Status__c == STATUS_ACTION_REQUIRED) && newService.Approval_Status__c == STATUS_PENDING_APPROVAL ) { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',completed required action'; } else if (newService.Resubmit__c == true) { newLine = now.escapeCsv() + ',' + userName.escapeCsv() + ',resubmitted the request'; } if (String.isNotBlank(newLine)) { newService.Approval_History__c = String.isNotBlank(oldApprovalHistory) ? oldApprovalHistory + '\n' + newLine : newLine; } else { newService.Approval_History__c = oldApprovalHistory; } } /** * Converts stored CSV-style approval history (GMT timestamp,user,note) into * readable display lines using the running user's date format for UI. * * @param services List of Opportunity_Right__c whose Approval_History__c will be formatted for UI */ @TestVisible private static void formatApprovalHistoryForUi(List services) { try { for (Opportunity_Right__c service : services) { if (String.isNotEmpty(service.Approval_History__c)) { List snapshot = new List(); for (String line : service.Approval_History__c.split('\n')) { List cells = line.split(','); Datetime dt = Datetime.valueOfGmt(cells[0]); snapshot.add('[' + dt.format(DATETIME_LOAD_FORMAT) + '] ' + cells[1] + ' ' + cells[2]); } service.Approval_History__c = String.join(snapshot, '\n'); } } } catch (Exception ex) { ErrorLogger.logError(ex); } } /** * Saves services with intelligent change detection. Compares against existing records and only * upserts those that have changed. Automatically sets approval status based on Service_Requested__c * flag and whether the Master Right has an assigned approver. * * @param services List of Opportunity_Right__c records to save (all from same Opportunity) * @return List The original services list, or null on error */ @AuraEnabled public static List saveServices(List services) { List servicesToUpdate = new List(); try { String oppId = services[0].Opportunity__c; String query = 'SELECT' + ' Master_Right__r.Name,' + ' Opportunity__r.CA_RoyaltyShare__c,' + String.join(getRightFields(), ',') + ' FROM Opportunity_Right__c' + ' WHERE Opportunity__c = :oppId' + ' AND Master_Right__r.Active__c = TRUE' + ' ORDER BY Master_Right__r.Order__c'; Map oldServices = new Map( (List) Database.query(query) ); if (oldServices.isEmpty()) { servicesToUpdate = services; } else { Map> fieldsByService = new Map>(); for (Master_Right__c masterService : [ SELECT Id, Name, ( SELECT Field_Name__c FROM Service_Tracker_Setup__r WHERE Opp_Field__c = FALSE ORDER BY Order__c ) FROM Master_Right__c ORDER BY Name ]) { if (!fieldsByService.containsKey(masterService.Name)) { fieldsByService.put(masterService.Name, new List()); fieldsByService.get(masterService.Name).addAll(COMMON_FIELDS); } for (Service_Tracker_Setup__c setupRecord : masterService.Service_Tracker_Setup__r) { fieldsByService.get(masterService.Name).add(setupRecord.Field_Name__c); } } for (Opportunity_Right__c service : services) { Opportunity_Right__c oldService = oldServices.get(service.Id); if (oldService != null) { for (String field : fieldsByService.get(service.Master_Right__r.Name)) { if (oldService.get(field) != service.get(field)) { servicesToUpdate.add(service); break; } } } } } Map oppsToUpdate = new Map(); for (Opportunity_Right__c service : servicesToUpdate) { if (!service.Service_Requested__c) { service.Approver_Name__c = null; service.Decision_Time__c = null; service.Approval_Status__c = null; service.Approval_Snapshot__c = null; service.Acquired_Opportunity__c = null; service.Approver_Notes__c = null; service.Bypass_Approval__c = false; } else if ( service.Approval_Status__c == null || service.Approval_Status__c == STATUS_PENDING_APPROVAL || service.Resubmit__c ) { if (service.Master_Right__r.Approver__c == null || service.Bypass_Approval__c) { service.Approval_Status__c = STATUS_AUTOAPPROVED; service.Acquired_Opportunity__c = service.Opportunity__c; } else { service.Approval_Status__c = STATUS_PENDING_APPROVAL; service.Approver_Notes__c = null; } } Opportunity_Right__c oldService = oldServices.get(service.Id); if (service.Master_Right__r.Name == ROYALTY_SHARE_SERVICE && oldService != null) { Boolean setRoyaltyShare = null; if ( service.Approval_Status__c == STATUS_AUTOAPPROVED && !oldService.Opportunity__r.CA_RoyaltyShare__c ) { setRoyaltyShare = true; } else if ( service.Approval_Status__c == STATUS_PENDING_APPROVAL && oldService.Opportunity__r.CA_RoyaltyShare__c ) { setRoyaltyShare = false; } if (setRoyaltyShare != null) { oppsToUpdate.put( service.Opportunity__c, new Opportunity(Id = service.Opportunity__c, CA_RoyaltyShare__c = setRoyaltyShare) ); } } if ( (oldServices.isEmpty() || oldServices.get(service.Id).Service_Requested__c == false) && (service.Service_Requested__c == true || service.Resubmit__c == true) ) { service.Request_Time__c = System.now(); } else if ( (!oldServices.isEmpty() && oldServices.get(service.Id).Service_Requested__c == true) && service.Service_Requested__c == false ) { service.Request_Time__c = null; } formatApprovalHistoryForDb(service, oldServices.get(service.Id)); service.Resubmit__c = false; } upsert servicesToUpdate; if (!oppsToUpdate.isEmpty()) { TriggerControl.disableTrigger(OpportunityTriggerHandler.SELF); update oppsToUpdate.values(); TriggerControl.enableTrigger(OpportunityTriggerHandler.SELF); } formatApprovalHistoryForUi(servicesToUpdate); return services; } catch (Exception ex) { ErrorLogger.logError(ex); return null; } } /** * Builds a ServiceTrackerSetupFieldWrapper instance from a field's describe result and setup configuration. * Determines appropriate input types and field characteristics based on the field's metadata. * * @param describeField Schema.DescribeFieldResult for the field * @param setup Service_Tracker_Setup__c record containing field configuration * @return ServiceTrackerSetupFieldWrapper Configured field metadata for UI rendering */ private static ServiceTrackerSetupFieldWrapper buildServiceTrackerSetupFieldWrapper( DescribeFieldResult describeField, Service_Tracker_Setup__c setup ) { String fieldType = describeField.getType().name(); String api = describeField.getName(); String label = describeField.getLabel(); String type; String inputType; Integer decimals; if (FormatUtils.NUMERIC_TYPES.contains(fieldType)) { type = 'input'; decimals = describeField.getScale(); if (fieldType == FormatUtils.FIELD_TYPE_PERCENT) { inputType = 'percent'; } else { inputType = 'number'; } } else if (fieldType == Schema.DisplayType.DATE.toString()) { type = 'input'; inputType = 'date'; } else if (fieldType == Schema.DisplayType.TEXTAREA.toString()) { type = 'textarea'; } else if (fieldType == Schema.DisplayType.PICKLIST.toString()) { type = 'picklist'; } else if (fieldType == Schema.DisplayType.MULTIPICKLIST.toString()) { type = 'multipicklist'; } else if (fieldType == Schema.DisplayType.BOOLEAN.toString()) { type = 'input'; inputType = 'checkbox'; } else { type = 'input'; inputType = 'text'; } return new ServiceTrackerSetupFieldWrapper( type, api, label, inputType, decimals, setup.Opp_Field__c, setup.Territory_Field__c, setup.Dependant_Field__c, setup.Dependant_Value__c, setup.Help_Text__c ); } /** * Retrieves service tracker hub data. * * @return ServiceTrackerHubDataWrapper A wrapper object containing aggregated service tracker hub data. */ @AuraEnabled public static ServiceTrackerHubDataWrapper getServiceTrackerHubData() { List reports = [ SELECT Id, Name FROM Report WHERE FolderName = :ST_REPORTS_FOLDER ORDER BY Name ]; return new ServiceTrackerHubDataWrapper(reports, true); } public class ServiceTrackerHubDataWrapper { @AuraEnabled public List reports { get; set; } @AuraEnabled public Boolean isApprover { get; set; } /** * @description Constructor for ServiceTrackerHubDataWrapper that initializes the wrapper * @param reports List of Report objects to be stored in the wrapper * @param isApprover Boolean flag indicating whether the current user has approver permissions */ public ServiceTrackerHubDataWrapper(List reports, Boolean isApprover) { this.reports = reports; this.isApprover = isApprover; } } public class ServiceTrackerSetupFieldWrapper { @AuraEnabled public String type { get; set; } @AuraEnabled public String api { get; set; } @AuraEnabled public String label { get; set; } @AuraEnabled public String inputType { get; set; } @AuraEnabled public Integer decimals { get; set; } @AuraEnabled public Boolean isOppField { get; set; } @AuraEnabled public Boolean isTerritoryField { get; set; } @AuraEnabled public String dependantField { get; set; } @AuraEnabled public String dependantValue { get; set; } @AuraEnabled public String helpText { get; set; } /** * Full constructor with all available fields * @param type The field data type (e.g., 'String', 'Boolean') * @param api The API name of the field (e.g., 'Right__c') * @param label The user-facing label * @param inputType The preferred input type for UI (e.g., 'checkbox', 'select') * @param decimals Number of decimal places for number fields * @param isOppField Whether this is an Opportunity field * @param isTerritoryField Whether this is a Territory field * @param dependantField Name of the field this field depends on * @param dependantValue Value of the dependent field that controls this field * @param helpText Help text for the field */ public ServiceTrackerSetupFieldWrapper( String type, String api, String label, String inputType, Integer decimals, Boolean isOppField, Boolean isTerritoryField, String dependantField, String dependantValue, String helpText ) { this.type = type; this.api = api; this.label = label; this.inputType = inputType; this.decimals = decimals; this.isOppField = isOppField; this.isTerritoryField = isTerritoryField; this.dependantField = dependantField; this.dependantValue = dependantValue; this.helpText = helpText; } } }