ScheduleHoursDistributor is a Salesforce Apex class that generates PSA (Certinia / FinancialForce) Schedule Exception records by distributing a total number of planned hours across multiple project phases (Cat1, Cat2, Cat3, and optionally Post) using a bell-curve–style schedule.
Its primary purpose is to create a realistic workload pattern where scheduled hours start low, gradually increase to a peak, and then taper off again, rather than being evenly or manually assigned.
The class is designed to be invoked from Salesforce Flow and automatically handles:
- Bell-curve–based hour allocation by category
- Holiday exclusion
- Workday-based calculations
- Optional onsite periods with gap handling
The overall goal is to replace manual schedule exception entry with consistent, rule-driven automation that produces more natural, balanced schedules.
-
Invoked from Flow
- The class exposes an
@InvocableMethodthat accepts aList<ScheduleInput>, supporting batch processing of multiple schedules in a single invocation.
- The class exposes an
-
Input Validation
- Validates required fields such as schedule dates, total hours, workdays, category weeks, and percentages.
numberOfHoursmust be greater than zero — a zero or negative value returns an explicit validation error.- All week values (
cat1Weeks,cat2Weeks,cat3Weeks,postWeeks) must be zero or positive — negative values return an explicit validation error. - All percentage values (
cat1Percentage,cat2Percentage,cat3Percentage,postPercentage) must be zero or positive — negative values return an explicit validation error. numberOfWorkDaysis bounded to the supported range1..7before allocation and exception-field population.- Ensures onsite dates (if provided) are valid and complete.
-
Holiday Processing
- Holiday dates are extracted from existing schedule exceptions.
- These dates are excluded from hour calculations and exception creation.
-
Onsite Logic (Optional)
- If an onsite period is detected (< 30 days and within the schedule range):
- A Monday–Sunday onsite gap week is calculated.
- Weekday normalization is runtime-context aware so Monday is always treated as day 1 regardless of locale ordering.
- The generated onsite gap exception is clamped to the input schedule range.
- The gap week receives a zero-hour schedule exception.
- All category allocations exclude the onsite gap.
- The Post category is created only when onsite is valid.
-
Category Range Calculation
- Category durations are converted from weeks to days (
weeks × 7, decimals truncated). - Categories are calculated backward from the schedule end date.
- The Post category is constrained by
postWeeks(postWeeks × 7days) and capped to the schedule end date. - Each category is trimmed to the overall schedule window.
- Categories that do not overlap the schedule are ignored.
- Category durations are converted from weeks to days (
-
Percentage Redistribution
- Only valid categories are included.
- Percentages are normalized so the total always equals 100%.
- Any unused or excess percentage is redistributed evenly.
-
Hour Distribution
- Usable days are counted per category using
numberOfWorkDays. - Workday patterns are:
1 = Mon,5 = Mon–Fri,6 = Mon–Sat,7 = Mon–Sun. - Holidays and onsite gap days are excluded.
- Daily hours are calculated using a capacity-aware allocation model.
- A hard cap of
24hours/day is enforced. - Overflow is redistributed across categories with remaining capacity (up to 4 passes).
- Usable days are counted per category using
-
Schedule Exception Creation
- Category ranges are split at holidays and onsite gap dates.
- Each uninterrupted segment becomes its own
pse__Schedule_Exception__crecord. - All generated exceptions are returned to Flow.
-
Remainder Handling
- If requested hours exceed total schedulable capacity, the method does not throw an error.
- The method schedules as many hours as possible and leaves the remainder unscheduled.
- This allows planned hours and scheduled hours to differ when users over-assign hours.
This method is designed to be called from Salesforce Flow and serves as the single entry point for schedule exception generation. It accepts a List<ScheduleInput> and returns a List<ScheduleExceptionOutputWrapper>, allowing batch processing of multiple schedules in one call.
The ScheduleInput class provides all parameters required for processing.
scheduleIdstartDateendDatenumberOfHoursnumberOfWorkDays— number of working days per week. Values are bounded to1..7(e.g.,5for Mon–Fri,7for all days).
cat1Weeks,cat2Weeks,cat3Weeks,postWeekscat1Percentage,cat2Percentage,cat3Percentage,postPercentage
onsiteStartDateonsiteEndDate
existingExceptions
Used to identify holiday dates that must be excluded from calculations. Existing exceptions are never included in the output — only new exceptions generated by the method are returned.
The method returns one wrapper per input containing:
-
scheduleExceptions
A list of newly generatedpse__Schedule_Exception__crecords. -
errorMessage
A descriptive message populated when validation or execution fails.
If successful, this value isnull.
- Cat 1
- Cat 2
- Cat 3
- Post (created only when a valid onsite period exists)
- Category durations are calculated in days, not weeks:
weeks × 7(decimals truncated)
- Categories are calculated backward from the schedule end date
- Categories that do not overlap the schedule range are ignored
- Hours are assigned to the first
numberOfWorkDaysdays of each week (for example:5 = Mon–Fri,7 = Mon–Sun) - Usable-day counting uses the same
numberOfWorkDayspattern as hour assignment - Out-of-range
numberOfWorkDaysinputs are consistently bounded to1..7across both day counting and schedule-exception day fields - Holidays and onsite gap days are always excluded
- Category percentages are normalized so valid categories total 100%
- Capacity is calculated per category as
usableDays × 24 - Initial assignment is percentage-based, then capped by category capacity
- Overflow is rolled into other categories with available capacity
- Redistribution is limited to a maximum of 4 passes
- Per-day assigned hours are capped at 24
- If capacity is exhausted, remaining requested hours are intentionally left unscheduled (no error)
Onsite is considered valid only if:
- Duration is less than 30 days
- Fully contained within the schedule range
- A Monday–Sunday onsite gap week is calculated
- The generated onsite gap exception is clamped to the input schedule window (
startDatetoendDate) - The gap week generates a zero-hour schedule exception
- All category allocations exclude onsite gap days
- The Post category begins after the onsite gap week ends and lasts up to
postWeeks × 7days, capped byendDate
The following helper methods are public and can be called directly in unit tests or other Apex code:
Extracts all individual dates covered by a list of schedule exceptions (used to identify holiday dates). Handles single-day and multi-day exceptions, as well as null entries in the list.
createScheduleException(Id scheduleId, Date startDate, Date endDate, Integer workDays, Decimal hoursPerDay)
Creates and returns a single pse__Schedule_Exception__c record with per-day hours populated based on the workDays count. workDays is bounded to 1..7 (1 = Mon only, 5 = Mon–Fri, 7 = all days).
addSplitExceptions(Date segStart, Date segEnd, Decimal hoursPerDay, Integer workDays, Id scheduleId, Date inputStart, Date inputEnd, Set<Date> allSplits, List<pse__Schedule_Exception__c> allExceptions, List<pse__Schedule_Exception__c> catList)
Iterates a date range and splits it into contiguous segments at every holiday and onsite gap date. Each uninterrupted segment is created as a separate pse__Schedule_Exception__c record added to both allExceptions and catList.
Counts usable days between two dates based on numberOfWorkDays (1-7), excluding any dates in excludeDates (holidays and onsite gap days).
Backward-compatible overload that defaults to 5 (Mon–Fri).
Returns true when a date falls within the configured weekly work pattern (1 = Mon through 7 = Mon–Sun).
Returns true if the given date falls on Monday through Friday.
Returns a normalized weekday index where Monday = 1 and Sunday = 7. The method adapts to runtime locale/context ordering so downstream scheduling logic remains stable.
The test class ScheduleHoursDistributorTest provides comprehensive coverage for the main class. Tests are written as standard Salesforce Apex @isTest unit tests.
@testSetup creates two pse__Schedule__c records and several pse__Schedule_Exception__c holiday records that are shared across tests:
- Schedule 1 — Jan 1 to Jul 1, 2025 (200 hours, 7-day work week)
- Schedule 2 — Jan 1 to Jul 1, 2026 (100 hours, 5-day work week)
- Holiday exceptions — Jan 8 2025 (single-day), Mar 10–12 2025 (multi-day), Mar 13 2025 (single-day)
| Test Method | What It Validates |
|---|---|
testGenerateScheduleExceptions |
Happy path — valid input with onsite generates exceptions without errors |
testGenerateScheduleExceptionsWithNullScheduleId |
Null scheduleId returns a specific validation error |
testStartDateAfterEndDateValidation |
startDate > endDate returns validation error |
testOnsiteStartAfterEndDateValidation |
onsiteStartDate > onsiteEndDate returns validation error |
testNegativeNumberOfHours_ReturnsError |
numberOfHours <= 0 returns an explicit validation error instead of silently under-allocating |
testNegativeCatWeeks_ReturnsError |
Negative week values return an explicit validation error |
testNegativePercentages_ReturnsError |
Negative percentage values return an explicit validation error |
testExistingMultiDayExceptionExcludesHolidayDates |
No generated exception overlaps a multi-day holiday range |
testOnsiteGapWeekIsSevenDaysZeroHours |
Onsite gap exception is found by its exact computed Monday start date; asserts it is exactly 7 days spanning Monday–Sunday with all per-day hours zero |
testGetNormalizedDayOfWeek_MondayIsOne |
Runtime normalization always maps Monday to 1 and Sunday to 7 |
testNumberOfWorkDaysOutOfRange_IsConsistentEverywhere |
Out-of-range numberOfWorkDays values are bounded consistently for usable-day counting and exception day-field population |
testPercentSumLessAndGreaterThan100 |
Percentages summing to < 100 or > 100 are normalized correctly |
testMathWithoutExistingExceptions_TotalHoursMatch |
Scheduled hours do not exceed requested hours |
testMathWithExistingHolidayExcludesDate_TotalHoursMatch |
Holiday dates are excluded and scheduled hours do not exceed requested hours |
testPerCategoryHoursMatchConfiguredPercentages |
Hours allocated to each category exactly match the configured percentages — cat1 receives 60% and cat2 receives 40% of total hours, verified per-category not just as an aggregate total |
testExtractHolidayDates_NullAndRanges |
extractHolidayDates handles null entries, single-day, and multi-day exceptions |
testAddSplitExceptions_SplittingBehavior |
addSplitExceptions correctly splits segments at split dates; asserts exact segment count and precise start/end boundaries for every produced segment |
testCountUsableWorkdays_VariousRanges |
countUsableWorkdays counts correctly for 3-day, 6-day, and 7-day schedules, including exclusions |
testAllCategoriesInvalid_NoCrash |
Zero-week categories produce no exceptions without throwing an error |
testGenerateScheduleExceptions_MultipleValidInputs |
Batch processing of two valid inputs returns two wrappers |
testGenerateScheduleExceptions_MixedValidAndInvalidInputs |
Batch processing of valid + invalid input returns one success and one error |
testOnsiteGapClampedToInputDateRange |
Onsite gap exception and all generated exceptions remain within input start/end boundaries |
testPostWeeksConstrainsPostDateRange |
Post category starts exactly on the day after the onsite gap week ends and ends exactly on the postWeeks × 7 derived date; no exception extends beyond that date |
testGenerateScheduleExceptions_TwoValidInputs_NoErrors |
Two valid inputs both return wrappers with no errors |
testCapacityCapAndOverflowDropped_NoError |
Per-day hours are capped at 24, overflow beyond capacity is dropped, and no error is returned |