Monday, June 18, 2012

Time Slicing Organized Labor

Flex has supported multiple pricing models for some time, and the ability to make one pricing model a multiple of another, but as we started taking a second look at Flex's labor functionality we realized we'd need to introduce more flexible ways of calculating pricing without requiring customers to use the overly-technical embedded rules engine.

We needed something with the flexibility and power of a full blown rules engine, but simple enough that non-technical users could reasonably understand and maintain the pricing logic.  We came up with something we call Tiered Pricing - where the pricing logic can be broken into any number of independent tiers.  Each tier has rules that determine when it will be in force for a given calculation and what values will be used in the pricing calculation.

The screen shot below shows a sample pricing tier:


On the left side are the rules that govern when this tier is matched or selected and on the right the rules for how the tier is calculated.

This example shows a tier that, as part of a set of labor pricing tiers, calculates holiday overtime.

The matching criteria determine what counts as holiday overtime and the formula criteria determine what the pricing engine will do once it determines the matching criteria have been met.  If you look at the Multiplier on the right hand side, the formula criteria tells us that holiday overtime will be charged at double time and the Time Quantity Offset tells the system not to count the first eight hours.

(What happens with the first 8 hours?  That's a different tier: Holiday Straight Time.)

Less Abstract, Please

It might be easier to understand pricing tiers if we consider a few examples of how they might be used.  Since the impetus behind designing pricing tiers was handling the complex labor pricing rules we often see in the entertainment and production industry, let's start with those.

For example, it's common for local labor unions like IATSE to have rules about minimum hours, penalties for early or late call times and the higher rates for working on holidays or weekends.

Suppose a local stagehand's union has a four hour minimum call time and charges time and a half for working before 6 AM.  Then assume you need stagehands for a load in scheduled from 5AM to 8 AM.  This is only three hours, so it should trigger the minimum requirement and the call time is before 6 AM, so that first hour should be charged at time and a half.  For this example to work, you'd need two tiers: one matching the after hours times and one matching normal working hours.  On the calculation side, both tiers would probably need a four hour minimum.

Using our tiered system, when you add this call to a quote, you'd see the following:


In this example, you can see that the first hour is billed time and a half and the next two hours are billed at straight time.  The system also detected the four hour minimum and added an extra hour to the last tier.

Time Slicing

As it turned out, getting this simple example to behave as expected was quite a challenge.  We designed our pricing tier system around matching the call time information to a pricing tier.  Time based matching in the default mode is based on the start time of the call.  Without some extra elbow grease, the previous example would have matched the after hours tier since the call starts in that tier's matching criteria - and not the straight time tier.  This would have changed our separate After Hours and Normal tiers to just one instance of the After Hours tier - with all four hours placed in that tier and billed at that rate.  Some union rules are based on when the call starts and this would have been the appropriate way to handle it.  Most situations, however, would require the system to transition to a different set of pricing tiers once the call shifts into normal working hours.  To implement this functionality, we created an alternate mode of matching pricing tiers called time slicing.

The diagram above shows an example of a very long call typical (and perhaps a little optimistic) for stagehands working all day for a tour load-in through load-out, from when the riggers show up early in the morning to when the show is back on the trucks.  To properly price the labor for a long call like this, we're looking at four different pricing tiers: After hours for the early call, straight time, normal overtime, and after hours overtime.

To make this work, our time slicing code has to figure out what times of day trigger changes in the pricing rules, divide the call up into slices corresponding with those times, and run the matching logic separately for each slice.  It has to somehow keep track of the cumulative time while doing all this so that overtime rules get triggered correctly, even if the hours that contribute to triggering overtime come from different time slices.

The algorithm we ended up implementing looks like this:

        if (model.getTimeSlicing()) {
            boolean lastTimeSlice = false;
            float unprocessedTime = params.getTimeQuantity();
            float processedTime = 0f;
            PricingCalculationParameters batchParams = (PricingCalculationParameters)params.clone();
            int reliefValve = 0;
            Map<String, PricingTierCalculationResult> resultMap = new LinkedHashMap<String, PricingTierCalculationResult>();
            while ( (unprocessedTime > 0) && (++reliefValve < 1000) ) {
               
                //first matching attempt is really just to help determine the time slice
                //once the slice duration is established, we rematch
               
                Collection<PricingTierCalculationResult> calcResults = matchTiers(tiers, null, batchParams);
                DateTime startTime = new DateTime(batchParams.getStartDate());
                DateTime endTime = new DateTime(batchParams.getEndDate());
                DateTime nextEndTime = null;
                DateTime candEndTime = null;
                for (PricingTierCalculationResult cand : calcResults) {
                    if (!cand.isMatched()) {
                        continue;
                    }
                    if (cand.getTier().getEndEffectiveTime() != null) {
                        LocalTime time = new LocalTime(cand.getTier().getEndEffectiveTime());
                        candEndTime = new DateTime(startTime.getYear(), startTime.getMonthOfYear(), startTime.getDayOfMonth(), time.getHourOfDay(), time.getMinuteOfHour());
                        if (candEndTime.isAfter(startTime)) {
                            cand.setAdjustedTimeQuantity(dateDiffInHours(startTime, candEndTime));
                            if ( (nextEndTime == null) || candEndTime.isBefore(nextEndTime)) {
                                nextEndTime = candEndTime;
                            }
                        }
                    }
                }
               
                if (nextEndTime == null) {
                    //set the next end time to midnight - this allows us to check for holidays, etc
                    nextEndTime = new DateTime(startTime.getYear(), startTime.getMonthOfYear(), startTime.getDayOfMonth(), 0, 0);
                    nextEndTime = nextEndTime.plusDays(1);
                }
               
                //this means we're on the last time slice and we're done
               
                if (nextEndTime.isAfter(endTime)) {
                    nextEndTime = endTime;
                    lastTimeSlice = true;
                }
               
                double sliceDuration = dateDiffInHours(startTime, nextEndTime);
               
                processedTime += sliceDuration;
                unprocessedTime -= sliceDuration;
                batchParams.setTimeQuantity(new Float(processedTime));
                calcResults = matchTiers(tiers, null, batchParams);
                           
                if (unprocessedTime <= 0) {
                    lastTimeSlice = true;
                }
               
                //adjust time quantities for slice duration
                PricingTierCalculationResult existingResult = null;
                for (PricingTierCalculationResult cand : calcResults) {
                    System.out.println(cand.getTier().getMatchingExpression());
                    cand.setAdjustedTimeQuantity(sliceDuration);
                    cand.setTimeQuantity(sliceDuration);
                    cand.setIgnoreTimeMinimums(true);
                    existingResult = resultMap.get(cand.getTier().getObjectIdentifier());
                    if (existingResult != null) {
                        cand.setTimeQuantity(cand.getTimeQuantity() + existingResult.getTimeQuantity());
                        cand.setAdjustedTimeQuantity(cand.getTimeQuantity());
                    }
                                       
                    double previousProcessedTime = processedTime - sliceDuration;
                    if ( (cand.getTier().getMaxTimeQty() != null) && (cand.getTier().getMaxTimeQty() > 0) && (cand.getTier().getMaxTimeQty() < previousProcessedTime) ) {
                        continue;
                    }
                    if ( (cand.getTier().getTimeQuantityOffset() < 0) && (Math.abs(cand.getTier().getTimeQuantityOffset()) <  previousProcessedTime)) {
                        cand.setIgnoreTimeOffset(true);
                    }
                    resultMap.put(cand.getTier().getObjectIdentifier(), cand);
                    //minimums are only enforced on the last matching time slice
                    if (lastTimeSlice && cand.getTier().getMinTimeQty() != null) {
                        if (  processedTime < cand.getTier().getMinTimeQty()) {
                            cand.setTimeQuantity(cand.getTier().getMinTimeQty().doubleValue() - processedTime + sliceDuration);
                            cand.setAdjustedTimeQuantity(cand.getTier().getMinTimeQty().doubleValue() - processedTime + sliceDuration);
                        }
                    }
                   
                }
               
               
                if (lastTimeSlice) {
                    break;
                }
               
               
               
                batchParams = (PricingCalculationParameters)batchParams.clone();
                batchParams.setStartDate(nextEndTime.toDate());
                batchParams.setTimeQuantity(unprocessedTime);
            }
           
           
            results = resultMap.values();
           
        }
        else {
            matchTiers(tiers, results, params);
        }
In a nutshell, this code starts by determining the total length of the call, initializes a variable called unprocessedTime with that value and then enters a while loop that keeps executing until the unprocessedTime value is 0.

In each loop iteration the matcher is executed twice, first to determine the length of the current time slice and a second time with the calculated time slice duration as an input value.

The length of the time slice is determined by selecting the earliest of any effective end times that may be configured for matched tiers.  A hard break is also placed at midnight so the time slicing logic can detect any transitions between holidays or weekends.  The next end time value is considered the end of the time slice and is compared with the start time to determine its total length, then this value is added to the currently processed time and fed into the matching algorithm.  This weeds out situations where overtime tiers may have matched for the total call duration, but not for a shorter time slice.

Once the results come back from the second matcher invocation, some logic is used to bypass standard minimum calculations (you don't want each time slice triggering minimums; the call has to be considered a whole).  You can see a branch where minimums are enforced if the loop is on the last pricing tier.  We also consolidate tiers that are matched in multiple time slices to prevent confusing clutter in the finished quote.

In Summary

So there you have it, a sneak preview of our new labor pricing system and a look at one of the more interesting bits of code required to make everything work.  We still have some polishing to do, but the new labor system is essentially finished and drops to QA this week.  The next major release of Flex 4 will include this new functionality.  After that, we'll veer off into high speed asynchronous scanning modes and then it's back to Phase 2 of labor where the emphasis will shift from financials to scheduling and conflict resolution.

No comments:

Post a Comment