3

I am struggling with finding a blueprint (or a solution) to a batch class that would be run every 1-2 days and recalculate a checkbox on Event records which were updated within the last 2 days. Any idea would help at the moment. The problem is that there are different data grouping criterion which should all apply.

Assignment

For every calendar quarter within the same year, it is required to mark the first starting Event record using field called FirstEvent__c. Flag the first record as TRUE and other records with StartDate higher than the first record as FALSE. Record groups are made by selecting all Events related to Opportunities under the same Account, with the same Owner and in the same Calendar year & quarter.

Data group criterion

  • Account - only Events that are linked to Opportunities with the same Account are considered (if Account Acme has 10 child Opportunities, only events related to Opportunities of Acme Account are considered within a group of data)
  • Owner - Events are grouped by the same owner
  • Calendar year and calendar quarter (for example StartDate of an Event is 01/01/2022 - considered as first quarter of year 2022)

Example fiscal quarters

  • 1/1/2021 - 3/31/2021 - first quarter of year 2021
  • 4/1/2021 - 6/30/2021 - second quarter of year 2021
  • 7/1/2021 - 9/30/2021 - third quarter of year 2021
  • 10/1/2021 - 12/31/2021 - fourth quarter of year 2022
  • 1/1/2021 - 3/31/2022 - first quarter of year 2022
  • 4/1/2021 - 6/30/2022 - second quarter of year 2022
  • etc.

Example data

  1. Account Acme has 2 Opportunities, each Opportunity has 5 events in the same year and quarter. Each event has the same owner Id. Only the first Event (with the lowest value of StartDate) in this case should be flagged FirstEvent__c = TRUE.
  2. Account Acme has 2 Opportunities, each Opportunity has 5 events in the same year and quarter. Each event has a different OwnerId. All Events in this case should be flagged FirstEvent__c = TRUE.
  3. Account Acme has 2 Opportunities, each Opportunity has 5 events where every Event is in a different quarter and year. Each event has the same owner Id. All Events in this case should be flagged FirstEvent__c = TRUE.

A non bulkified solution working for only 1 record

global Database.QueryLocator start(Database.BatchableContext bc) {
    return Database.getQueryLocator([SELECT Id, FirstEvent__c FROM Event WHERE (LastModifiedDate = LAST_N_DAYS:2)]);
}

global void execute(Database.BatchableContext bc, List<Event> batchEvents){

    List<Event> updates = new List<Event>();
    
    for(Event e1 : batchEvents){
        //Code is not bulkified
        if(e1.size()!=1){
            return;
        }

        //Calculating calendar year and month
        Integer year = e1.StartDateTime.year();
        Integer quarter = (e1.StartDateTime.month() / 3.0).round(System.RoundingMode.CEILING).intValue();
        
        Set<id> opps = new Set<Id>();
        //Only Events linked to an Opportunity are considered
        if(ev.WhatId.getSObjectType().getDescribe().getName() == 'Opportunity'){
            opps = new Map<id, Opportunity>([Select Id from Opportunity where AccountId=:ev.WhatId.AccountId]).keySet();
        } else {
            return;
        }

        //SOQL for Events grouped by Opportunities under the same Account, OwnerId and calendar month and year
        //Ordered by StartDateTime to later check the first Event
        List <Event> events = [SELECT id, FirstEvent__c FROM Event WHERE WhatId=:opps AND OwnerId=:e1.OwnerId AND CALENDAR_YEAR = :year AND CALENDAR_MONTH = :quarter ORDER BY StartDateTime];
         
        //Logic for checking the first event as true and others as false
        Boolean first = true;
        for(Event e2 : events){
            if(first){
                e2.FirstEvent__c = true;
                first = false;
            } else {
                e2.FirstEvent__c = false;
            }
            updates.add(e2);
         }
    }  
    update updates;
}

My initial idea (it's trash)

My initial idea was to make a map of Account Ids that would link to a map of OwnerIds, that would link to a map of Calendar Quarters, which would link to a List of Events - basically copying the grouping structure, but this is probably very wrong:

Map<Id, Map<Id, Map<String, List<Event>>>> map1 = new Map<Id, Map<Id, Map<String, List<Event>>>>();

Maybe then looping through all the Events & Opportunities and forming data according to this structure and processing it in between.

Judging from the initial complexity of my code, I'd say that that has to be wrong. Please help me with at least forming the initial idea.

1
  • 1
    Welcome to Salesforce Stack Exchange (SFSE)! Is this a real-world business case or an exam, challenge, etc.? I'm asking because if it real-world, it seems to me your (and the business') best bet would be to do work on the front side rather than on the back side: when an Event is created, check to see if there are any other Events for the same Owner for the same Account in the same quarter that have the FirstEvent__c flag marked as TRUE. If not, mark it TRUE. The end.
    – Moonpie
    May 15 at 21:18

1 Answer 1

3

Background knowledge

In general, we (read: developers who customize Salesforce with code) tend to break things down into 3 phases:

  • Gather data
  • Query
  • Process the results

Beyond that, the goals become a lot more general "Computer Science"

  • Limit the amount of data you're working on
  • Figure out how to represent/store/access the data you care about

Some of the things/ideas that I think will help you here:

  • Realize that you only need to concern yourself with the Tasks you previously set FirstEvent__c to true (and the Tasks you're currently working with).
  • Salesforce has a way or two to handle polymorphic relationships (like Event's WhatId) in queries
  • You can, when taking precautions, use inner classes and/or SObjects as the key of a Map or Set
  • The general order for governor limits that people tend to run into are:
    • Number of SOQL queries
    • Number of SOQL rows
    • Number of DML calls
    • CPU time
    • Heap space
    • Pretty much everything else (callouts, async calls, etc...)

The objective

With the above in mind, your main objective here is to find the minimum start date (per quarter) for each OpportunityId/OwnerId tuple among the tasks added in the previous 2 days.

If you can get that information for the previous 2 days, it's a simple task to find and compare that with the records currently marked as FirstEvent__c = true.

How to do it

I think the most important thing here is going to be deciding how to store your data in a map. For new developers (or experienced devs, just new to Salesforce), it's not recommended to use SObjects as the key of a map; but it is possible.

There are two things to realize about maps:

  • Non-primitive types (i.e. things that aren't Integer, String, Boolean, etc...) are stored in collections as references
  • Sets and the keys of Maps are basically stored as hashes

So if you take care not to modify any data that you use as a map key, it's fine to use as the map's key. Given a Task, you could use that to derive some data that contains both the Opportunity Id (via WhatId) and the owner (via OwnerId).

While you could use something like a Map<Task, List<Task>>, that'd give you an unsorted list of tasks per Opp-OwnerId tuple. You could sort each individual list yourself, but if you create an Apex class that implements the Comparable interface, you could get Salesforce to do the sorting for you (by calling <someList>.sort()). This could be its own Apex class, but I'd lean towards making it an inner class.

public class FirstTaskFinder{
    public class TaskSortedByDate implements Comparable{
        // This will be the thing we sort against
        public DateTime startDT;

        // Having a Task instance will help when you want to perform DML
        public Task generalTask;

        public TaskSortedByDate(Task t){
            startDT = t.StartDateTime;
            generalTask = t;
        }

        // The comparable interface needs you to implement the "compareTo" method.
        // You return +1 when this instance is greater than the "obj" instance,
        //   -1 when the "obj" instance is greater than this instance,
        //   and 0 when the two instances are "equal".
        // List.sort() always sorts in ascending order, so to get the task with
        //   the most recent StartDateTime as index 0, you'd simply reverse the
        //   comparison (swap ">" for "<", so that the larger StartDateTime returns
        //   -1)
        public Integer compareTo(Object obj){
            FirstTaskFinder otherObj = (FirstTaskFinder)obj;

            if(startDT < otherObj.startDT){ return 1; }
            else if(startDT > otherObj.startDT) { return -1; }
            else{ return 0; }
        }
    }

    public void someMethod(){
        // Step 1 is to gather data

        // For polymorphic relationships, you can specify what the target object is
        //   as part of the query itself (which means you don't need to do that
        //   processing via Apex)
        List<Task> givenTasks = [SELECT Id, StartDateTime, OwnerId, WhatId FROM Task WHERE WhatId.Type = 'Opportunity' AND CreatedDate >= N_DAYS_AGO:2];

        Map<Task, List<TaskSortedByDate>> OppAndOwnerIdToTaskList = new Map<Task, List<TaskSortedByDate>>();
        for(Task t :givenTasks){
            Task keyTask = new Task(
                OwnerId = t.OwnerId,
                WhatId = t.WhatId
            );

            // The generally accepted best practice for building maps is to check
            //   to see if the key exists, and add a default value if the key doesn't exist
            if(!OppAndOwnerIdToTaskList.containsKey(keyTask)){
                // After you put data into a set, or use it as the key of a map,
                //  YOU MUST NEVER ALTER THE INSTANCE OF YOUR KEY
                OppAndOwnerIdToTaskList.put(keyTask, new List<TaskSortedByDate>());
            }
    
            // That way, we can guarantee that there's a non-null value we can work with
            //  (i.e. no need to perform a null check from here on)
            OppAndOwnerIdToTaskList.get(keyTask).add(new TaskSortedByDate(t));
        }
    }

    // Using a Map here may not be necessary, but it does help ensure that
    //   you won't run into a "duplicate Id in List" error when you try to update
    //   data.
    Map<Id, Task> tasksToUpdate = new Map<Id, Task>();

    // I suppose that this would be the "query" step in gather-query-process,
    //   but we're not really using any data we've previously gathered here...
    // Only need to be concerned with what's currently marked as the first task, and
    //   that certainly limits the data that we're querying here (limiting the results
    //   is probably the most important part)
    // You could also probably limit this to tasks for the current quarter and beyond
    //   (if you're running into issues with the number of query rows)
    for(Task t :[SELECT Id, OwnerId, WhatId, StartDateTime FROM Task WHERE WhatId.Type = 'Opportunity' AND First_Task__c = true]){
        TaskSortedByDate currentFirstTaskKey = new TaskSortedByDate(t);

        List<TaskSortedByDate> workingTasks = OppAndOwnerIdToTaskList.get(currentFirstTaskKey);

        // It's possible that this "first task" isn't involved in the data we're
        //   working on.
        // In that case, there will be no changes that need to be made, and we
        //   can ignore this task.
        if(workingTasks == null){ continue; }

        workingTasks.sort();

        // If there's an earlier task in our new dataset, we need to mark
        //   that task as the first, and unmark the current task as the first.
        if(workingTasks[0].StartDateTime < t.StartDateTime){
            workingTasks[0].generalTask.First_Task__c = true;
            t.First_Task__c = false;

            tasksToUpdate.put(t.Id, t);
            tasksToUpdate.put(workingTasks[0].Id, workingTasks[0]);
        }
    }

    // We can't update a map, but if the value type isn't another collection, we
    //   can update the result of calling .values()
    update tasksToUpdate.values();
}

My example code here (very much untested) would give you the earliest start date regardless of the calendar year and quarter.

To include the calendar year and quarter in this, you'd likely want to create an additional inner class that contains WhatId, OwnerId, the year of the StartDateTime, and the quarter of the StartDateTime, and use instances of that as your map key instead of the more simple keyTask in my example.

It is possible that you'd end up choosing one Task as the "First Task" in one batch scope, and then choose another Task in the next batch scope, but those flip-flops should be minimal (especially if you sort by StartDateTime in your start() method). There's usually not really a good way to know if data in a scope in the future will supersede data in the current scope, but if you make sure that you select the optimal data (first, cheapest, etc...) in each scope then you should end up with the correct result.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.