Explorar o código

Improvements to Assignment Costing

Kenric Nugteren hai 3 semanas
pai
achega
8134ea6264

+ 32 - 0
prs.classes/Entities/Overtime/OvertimeInterval.cs

@@ -56,5 +56,37 @@ namespace Comal.Classes
             Multiplier = 1.0;
             IntervalType = OvertimeIntervalType.Interval;
         }
+
+        public static OvertimeInterval NewInterval(TimeSpan duration, double multiplier = 1.0)
+        {
+            return new OvertimeInterval
+            {
+                Interval = duration,
+                Multiplier = multiplier,
+                IsPaid = true,
+                IntervalType = OvertimeIntervalType.Interval
+            };
+        }
+
+        public static OvertimeInterval NewUnpaidInterval(TimeSpan duration)
+        {
+            return new OvertimeInterval
+            {
+                Interval = duration,
+                Multiplier = 0.0,
+                IsPaid = false,
+                IntervalType = OvertimeIntervalType.Interval
+            };
+        }
+
+        public static OvertimeInterval NewRemaining(double multiplier = 1.0)
+        {
+            return new OvertimeInterval
+            {
+                Multiplier = multiplier,
+                IsPaid = true,
+                IntervalType = OvertimeIntervalType.RemainingTime
+            };
+        }
     }
 }

+ 44 - 0
prs.classes/Settings/AssignmentCostSettings.cs

@@ -0,0 +1,44 @@
+using InABox.Configuration;
+using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Comal.Classes
+{
+    public enum AssignmentCostFillAlgorithm
+    {
+        /// <summary>
+        /// Each assignment is costed as <c>(Finish - Start) * HourlyRate</c>
+        /// </summary>
+        Basic,
+        /// <summary>
+        /// The assignments are stacked into a single block, and scaled to fit the duration of the <see cref="TimeSheet"/>, either up or down.
+        /// </summary>
+        /// <remarks>
+        /// Any unpaid overtime intervals are removed from the time sheet, so the total duration of the scaled assignments will be
+        /// <c>TimeSheet.Duration - Unpaid.Duration</c>
+        /// </remarks>
+        Scale,
+        /// <summary>
+        /// The assignments are, one by one, extended to fill the length of the timesheet; the first assignment will be adjusted to start at the beginning
+        /// of the day; any other assignments are extended to finish at the beginning of the next assignment. Assignments falling outside of the timesheet
+        /// are chopped, and thus are uncosted.
+        /// </summary>
+        Extend,
+        /// <summary>
+        /// The assignments are not adjusted in size, except for chopping overlapping blocks, and then lost time blocks are created to fill the space left
+        /// on the timesheet. Assignments outside of the timesheet are chopped. Overtime is taken into account, but the invented lost time blocks are only
+        /// transient, and forgotten about later.
+        /// </summary>
+        FillTimeSheet
+    }
+
+    public class AssignmentCostSettings : BaseObject, IGlobalConfigurationSettings
+    {
+        public AssignmentCostFillAlgorithm Algorithm { get; set; }
+
+        [ScriptEditor]
+        public string Script { get; set; }
+    }
+}

+ 2 - 2
prs.classes/Utilities/OvertimeUtils.cs

@@ -23,13 +23,13 @@ namespace Comal.Classes
         /// </remarks>
         public static void EvaluateOvertime<TBlock>(
             IEnumerable<TBlock> blocks,
-            OvertimeInterval[] overtimeIntervals,
+            IList<OvertimeInterval> overtimeIntervals,
             Func<TBlock, TimeSpan> durationSelector,
             EvaluateOvertimeDelegate<TBlock> evaluateBlock
         )
         {
             var curOvertimeIdx = 0;
-            OvertimeInterval? GetOvertimeInterval() => curOvertimeIdx < overtimeIntervals.Length ? overtimeIntervals[curOvertimeIdx] : null;
+            OvertimeInterval? GetOvertimeInterval() => curOvertimeIdx < overtimeIntervals.Count ? overtimeIntervals[curOvertimeIdx] : null;
             var curInterval = GetOvertimeInterval()?.Interval ?? TimeSpan.Zero;
 
             foreach (var block in blocks)

+ 2 - 0
prs.desktop/Panels/Assignments/CalendarPanel.cs

@@ -34,6 +34,8 @@ namespace PRSDesktop
             HumanResourcesSetupActions.EmployeeOvertimeRules(host);
             HumanResourcesSetupActions.EmployeeOvertime(host);
             HumanResourcesSetupActions.EmployeeStandardLeave(host);
+            host.CreateSetupSeparator();
+            HumanResourcesSetupActions.AssignmentCostingSettings(host);
         }
 
         public Dictionary<string, object[]> Selected()

+ 39 - 0
prs.desktop/Setups/HumanResourcesSetupActions.cs

@@ -1,8 +1,10 @@
 using Comal.Classes;
+using InABox.Configuration;
 using InABox.Core;
 using InABox.DynamicGrid;
 using InABox.Wpf;
 using PRSDesktop.Components.Spreadsheet;
+using PRSStores.AssignmentCosting;
 using System.Collections.Generic;
 using System.Linq;
 using System.Text;
@@ -120,4 +122,41 @@ public static class HumanResourcesSetupActions
             SpreadsheetTemplateGrid.ViewSpreadsheetTemplates<Employee>();
         });
     }
+
+    public static void AssignmentCostingSettings(IPanelHost host)
+    {
+        host.CreateSetupActionIfCanView<Assignment>("Assignment Costing Settings", PRSDesktop.Resources.edit, (action) =>
+        {
+            var settings = new GlobalConfiguration<AssignmentCostSettings>().Load();
+            if (DynamicGridUtils.EditObject(settings, customiseGrid: grid =>
+            {
+                grid.OnCustomiseEditor += Grid_OnCustomiseEditor;
+            }))
+            {
+                new GlobalConfiguration<AssignmentCostSettings>().Save(settings);
+            }
+        });
+    }
+
+    private static void Grid_OnCustomiseEditor(IDynamicEditorForm sender, AssignmentCostSettings[]? items, DynamicGridColumn column, BaseEditor editor)
+    {
+        if (items?.FirstOrDefault() is not AssignmentCostSettings settings) return;
+
+        if(column.ColumnName == nameof(AssignmentCostSettings.Script) && editor is ScriptEditor scriptEditor)
+        {
+            scriptEditor.Type = ScriptEditorType.TemplateEditor;
+            scriptEditor.OnEditorClicked += () =>
+            {
+                var script = settings.Script.NotWhiteSpaceOr()
+                    ?? AssignmentCostingUtils.DefaultScript();
+
+                var editor = new ScriptEditorWindow(script, SyntaxLanguage.CSharp);
+                if (editor.ShowDialog() == true)
+                {
+                    sender.SetEditorValue(column.ColumnName, editor.Script);
+                    settings.Script = editor.Script;
+                }
+            };
+        }
+    }
 }

+ 7 - 0
prs.shared/Posters/Timberline/TimesheetTimberlinePoster.cs

@@ -270,6 +270,13 @@ using System.Collections.Generic;
 
 public class Module
 {
+    /* A number of classes are present under the PRS.Shared.TimeSheetTimberline namespace to represent the data:
+       - ActivityBlock: a block of time with an activity, taken from a timesheet or an assignment.
+       - IBlock: base interface for blocks of time used by 'ProcessTimeBlocks'; this is either a
+         - PaidWorkBlock: representing time that the employee was working, or
+         - LeaveBlock: representing time the employee was on leave.
+    */
+
     public void BeforePost(IDataModel<TimeSheet> model)
     {
         // Perform pre-processing

+ 907 - 0
prs.stores/AssignmentCostingUtils.cs

@@ -0,0 +1,907 @@
+using Comal.Classes;
+using InABox.Configuration;
+using InABox.Core;
+using InABox.Database;
+using InABox.Database.Stores;
+using InABox.Scripting;
+using org.omg.CosNaming.NamingContextPackage;
+using sun.util.resources.cldr.haw;
+using Syncfusion.UI.Xaml.Grid.Converters;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRSStores.AssignmentCosting
+{
+    public class TimeSheetBlock
+    {
+        public TimeSpan Start { get; set; }
+        public TimeSpan Finish { get; set; }
+
+        public TimeSpan Duration => Finish - Start;
+
+        public required bool IsPaid { get; set; }
+    }
+
+    public class DayTimeSheet
+    {
+        public IList<TimeSheetBlock> Blocks { get; private init; }
+
+        public IEnumerable<TimeSheetBlock> PaidBlocks => Blocks.Where(x => x.IsPaid);
+
+        public TimeSpan PaidDuration => PaidBlocks.Aggregate(TimeSpan.Zero, (a, b) => a + b.Duration);
+
+        private DayTimeSheet(IList<TimeSheetBlock> blocks)
+        {
+            Blocks = blocks;
+        }
+
+        /// <summary>
+        /// Create a new <see cref="DayTimeSheet"/> from a given list of <see cref="TimeSheet"/>s. The <paramref name="overtime"/> is
+        /// used to create blocks for unpaid time on the timesheet.
+        /// </summary>
+        /// <exception cref="Exception">If there are no elements in <paramref name="sheets"/></exception>
+        public static DayTimeSheet FromTimeSheets(IEnumerable<TimeSheet> sheets, IList<OvertimeInterval> overtime)
+        {
+            var sheetArray = sheets.Select(x =>
+            {
+                return (
+                    start: x.Approved != DateTime.MinValue ? x.ApprovedStart : x.Start,
+                    finish: x.Approved != DateTime.MinValue ? x.ApprovedFinish : x.Finish,
+                    sheet: x);
+            }).ToArray();
+            sheetArray.SortBy(x => x.start);
+            if(sheetArray.Length == 0)
+            {
+                throw new Exception("No timesheets provided.");
+            }
+
+            // Merge all timesheets into a single list of time blocks; overlapping timesheets will be merged into one.
+            var blocks = new List<TimeSheetBlock>();
+            TimeSheetBlock? current = null;
+            foreach(var (start, finish, sheet) in sheetArray)
+            {
+                if(current is null || start > current.Finish)
+                {
+                    current = new TimeSheetBlock
+                    {
+                        Start = start,
+                        Finish = finish,
+                        // Doesn't matter what we put here, since this block will not appear in the final list.
+                        IsPaid = true
+                    };
+                    blocks.Add(current);
+                }
+                else
+                {
+                    current.Finish = finish;
+                }
+            }
+
+            // Now, apply overtime to take out chunks of time that are unpaid.
+            var newBlocks = new List<TimeSheetBlock>();
+            current = null;
+            TimeSheetBlock? currentUnpaidBlock = null;
+
+            // We will definitely have at least one block, based on the above algorithm, and since we ensured that sheetArray is non-empty.
+            var currentTime = TimeSpan.Zero;
+            var met = new HashSet<TimeSheetBlock>();
+            OvertimeUtils.EvaluateOvertime(blocks, overtime, x => x.Duration, (block, interval, duration) =>
+            {
+                if (interval is null) return;
+
+                if (met.Add(block))
+                {
+                    currentTime = block.Start;
+                }
+
+                if (interval.IsPaid)
+                {
+                    if(current is null || currentTime > current.Finish)
+                    {
+                        current = new TimeSheetBlock
+                        {
+                            Start = currentTime,
+                            Finish = currentTime + duration,
+                            IsPaid = true
+                        };
+                        newBlocks.Add(current);
+                        currentUnpaidBlock = null;
+                    }
+                    else
+                    {
+                        current.Finish = currentTime + duration;
+                    }
+                }
+                else
+                {
+                    if(currentUnpaidBlock is null || currentTime > currentUnpaidBlock.Finish)
+                    {
+                        currentUnpaidBlock = new TimeSheetBlock
+                        {
+                            Start = currentTime,
+                            Finish = currentTime + duration,
+                            IsPaid = false
+                        };
+                        newBlocks.Add(currentUnpaidBlock);
+                        current = null;
+                    }
+                    else
+                    {
+                        currentUnpaidBlock.Finish = currentTime + duration;
+                    }
+                }
+
+                currentTime += duration;
+            });
+
+            return new(newBlocks);
+        }
+    }
+
+    public class Block
+    {
+        public Assignment? Assignment { get; set; } // Set to null for lost time blocks that won't be costed anywhere.
+
+        public TimeSpan Start { get; set; }
+
+        public TimeSpan Finish { get; set; }
+
+        public TimeSpan Duration
+        {
+            get => Finish - Start;
+            set => Finish = Start + value;
+        }
+
+        public Block Copy()
+        {
+            return new Block
+            {
+                Assignment = Assignment,
+                Start = Start,
+                Finish = Finish,
+            };
+        }
+
+        public static Block FromAssignment(Assignment assignment)
+        {
+            return new Block
+            {
+                Assignment = assignment,
+                Start = assignment.EffectiveStartTime(),
+                Finish = assignment.EffectiveFinishTime()
+            };
+        }
+
+        public static Block Transient(TimeSpan start, TimeSpan finish)
+        {
+            return new Block
+            {
+                Assignment = null,
+                Start = start,
+                Finish = finish
+            };
+        }
+    }
+
+    public class BeforeProcessArgs(Assignment[] assignments, TimeSheet[] timesheets)
+    {
+        public Assignment[] Assignments { get; } = assignments;
+        public TimeSheet[] TimeSheets { get; } = timesheets;
+    }
+
+    public class AdjustTimeBlocksArgs(DayTimeSheet timeSheet, List<Block> blocks)
+    {
+        public DayTimeSheet TimeSheet { get; } = timeSheet;
+
+        public List<Block> Blocks { get; set; } = blocks;
+    }
+
+    public class ProcessTimeBlocksArgs(DayTimeSheet timeSheet, List<Block> blocks)
+    {
+        public DayTimeSheet TimeSheet { get; } = timeSheet;
+
+        public List<Block> Blocks { get; set; } = blocks;
+    }
+
+    public class AfterOvertimeArgs(DayTimeSheet timeSheet, List<Block> blocks, OvertimeInterval[] overtime, Assignment[] assignments)
+    {
+        public DayTimeSheet TimeSheet { get; } = timeSheet;
+
+        public List<Block> Blocks { get; } = blocks;
+
+        public OvertimeInterval[] Overtime { get; } = overtime;
+
+        public Assignment[] Assignments { get; } = assignments;
+    }
+
+    public interface IAssignmentCostingScript
+    {
+        void BeforeProcess(BeforeProcessArgs args);
+
+        void AdjustTimeBlocks(AdjustTimeBlocksArgs args);
+
+        void ProcessTimeBlocks(ProcessTimeBlocksArgs args);
+
+        void AfterOvertime(AfterOvertimeArgs args);
+    }
+
+    public class AssignmentCostingUtils : ISettingsStoreEventHandler<AssignmentCostSettings>
+    {
+        static AssignmentCostSettings? _costSettings;
+
+        public static AssignmentCostSettings GetSettings(IStore store)
+        {
+            _costSettings ??= new GlobalConfiguration<AssignmentCostSettings>("", new DbConfigurationProvider<GlobalSettings>(store.UserID))
+                .Load(false);
+            return _costSettings;
+        }
+
+        #region ISettingsStoreEventHandler
+
+        public IStore Parent { get; set; } = null!; // Set by GlobalSettingsStore
+
+        public void AfterDelete(AssignmentCostSettings entity)
+        {
+            _costSettings = null;
+            ClearScript();
+        }
+
+        public void AfterSave(AssignmentCostSettings entity)
+        {
+            _costSettings = entity;
+            ClearScript();
+        }
+
+        public void BeforeDelete(AssignmentCostSettings entity)
+        {
+        }
+
+        public void BeforeSave(AssignmentCostSettings entity)
+        {
+        }
+
+        #endregion
+
+        #region Script
+
+        public static string DefaultScript()
+        {
+            return
+@"using PRSStores.AssignmentCosting;
+using InABox.Core;
+using System.Collections.Generic;
+
+// 'Module' *must* implement " + nameof(IAssignmentCostingScript) + @"
+public class Module: " + nameof(IAssignmentCostingScript) + @"
+{
+    public void " + nameof(IAssignmentCostingScript.BeforeProcess) + @"(" + nameof(BeforeProcessArgs) + @" args)
+    {
+        // Perform pre-processing
+    }
+
+    public void " + nameof(IAssignmentCostingScript.AdjustTimeBlocks) + @"(" + nameof(AdjustTimeBlocksArgs) + @" args)
+    {
+        // At this stage, adjust the list of blocks (given by args." + nameof(AdjustTimeBlocksArgs.Blocks) + @") before any algorithm has
+        // been applied to them. The blocks are ordered by start time, and are represented by one per each assignment being processed. At this stage,
+        // they may overlap.
+
+        // args." + nameof(AdjustTimeBlocksArgs.Blocks) + @" will then be passed through algorithm given in the settings, which will fill
+        // up all the time on the day's timesheet by extending blocks as needed to fill all unfilled time.
+    }
+
+    public void " + nameof(IAssignmentCostingScript.ProcessTimeBlocks) + @"(" + nameof(ProcessTimeBlocksArgs) + @" args)
+    {
+        // Called after the time filling algorithm has been applied, but before the overtime rules. args." + nameof(ProcessTimeBlocksArgs.Blocks) + @" will
+        // now contain a list of blocks, filling continuous time from the start of the timesheet to its end. Process as desired.
+    }
+
+    public void " + nameof(IAssignmentCostingScript.AfterOvertime) + @"(" + nameof(AfterOvertimeArgs) + @" args)
+    {
+        // Once the overtime rules have been applied and the assignment cost calculated, post-process the assignments before saving to the database.
+    }
+}";
+        }
+
+        private static Type? _scriptObjectType;
+        private static bool _hasCheckedScript;
+
+        private static void ClearScript()
+        {
+            _scriptObjectType = null;
+            _hasCheckedScript = false;
+        }
+
+        private static Type? GetScriptObjectType(IStore store)
+        {
+            if (_hasCheckedScript)
+            {
+                return _scriptObjectType;
+            }
+
+            var settings = GetSettings(store);
+            if (!string.IsNullOrWhiteSpace(settings.Script))
+            {
+                var document = new ScriptDocument(settings.Script);
+                if (!document.Compile())
+                {
+                    Logger.Send(LogType.Error, store.UserID, "Script failed to compile!");
+                    _scriptObjectType = null;
+                }
+                else
+                {
+                    _scriptObjectType = document.GetClassType();
+                }
+            }
+            else
+            {
+                _scriptObjectType = null;
+            }
+
+            _hasCheckedScript = true;
+            return _scriptObjectType;
+        }
+
+        private static IAssignmentCostingScript? GetScriptObject(IStore store)
+        {
+            var type = GetScriptObjectType(store);
+            if(type is not null)
+            {
+                var obj = Activator.CreateInstance(type) as IAssignmentCostingScript;
+                if(obj is null)
+                {
+                    Logger.Send(LogType.Error, store.UserID, $"Assignment costing script module does not implement {typeof(IAssignmentCostingScript).Name}");
+                }
+                return obj;
+            }
+            else
+            {
+                return null;
+            }
+        }
+
+        private static void ScriptBeforeProcess(IAssignmentCostingScript? script, Assignment[] assignments, TimeSheet[] timesheets)
+        {
+            script?.BeforeProcess(new(assignments, timesheets));
+        }
+
+        private static List<Block> ScriptAdjustTimeBlocks(IAssignmentCostingScript? script, DayTimeSheet timeSheet, List<Block> blocks)
+        {
+            if(script is not null)
+            {
+                var args = new AdjustTimeBlocksArgs(timeSheet, blocks);
+                script.AdjustTimeBlocks(args);
+                return args.Blocks;
+            }
+            else
+            {
+                return blocks;
+            }
+        }
+
+        private static List<Block> ScriptProcessTimeBlocks(IAssignmentCostingScript? script, DayTimeSheet timeSheet, List<Block> blocks)
+        {
+            if(script is not null)
+            {
+                var args = new ProcessTimeBlocksArgs(timeSheet, blocks);
+                script.ProcessTimeBlocks(args);
+                return args.Blocks;
+            }
+            else
+            {
+                return blocks;
+            }
+        }
+
+        private static void ScriptAfterOvertime(IAssignmentCostingScript? script, DayTimeSheet timeSheet, List<Block> blocks, OvertimeInterval[] overtime, Assignment[] assignments)
+        {
+            script?.AfterOvertime(new(timeSheet, blocks, overtime, assignments));
+        }
+
+        #endregion
+
+        private static List<Block> AdjustTimeBlocksBasic(List<Block> blocks)
+        {
+            // No change for basic mode.
+            return blocks;
+        }
+
+        private static List<Block> AdjustTimeBlocksScale(DayTimeSheet timeSheet, List<Block> blocks)
+        {
+            // We scale the blocks until their total is equal to 'PaidDuration'
+            var totalBlockDuration = blocks.Aggregate(TimeSpan.Zero, (a, b) => a + b.Duration);
+            var adjustFactor = timeSheet.PaidDuration.TotalHours / totalBlockDuration.TotalHours;
+
+            // Adjust block size
+            foreach(var block in blocks)
+            {
+                block.Duration *= adjustFactor;
+            }
+
+            // Now we need to adjust the block positioning.
+            var paidBlocks = timeSheet.PaidBlocks.ToList();
+
+            var curPaidBlockIdx = 0;
+            TimeSheetBlock? GetPaidBlock() => curPaidBlockIdx < paidBlocks.Count ? paidBlocks[curPaidBlockIdx] : null;
+            var timeSheetStart = GetPaidBlock()?.Start ?? TimeSpan.Zero;
+            var curTime = timeSheetStart;
+            var curPaidBlockDuration = GetPaidBlock()?.Duration ?? TimeSpan.Zero;
+            void AdvancePaidBlock()
+            {
+                ++curPaidBlockIdx;
+                var paidBlock = GetPaidBlock();
+                curPaidBlockDuration = paidBlock?.Duration ?? TimeSpan.Zero;
+                if(paidBlock is not null)
+                {
+                    curTime = paidBlock.Start;
+                }
+            }
+
+            var newBlocks = new List<Block>();
+
+            foreach (var block in blocks)
+            {
+                var duration = block.Duration;
+                if(duration == TimeSpan.Zero)
+                {
+                    block.Start = curTime;
+                    block.Finish = curTime + duration;
+                    newBlocks.Add(block);
+                    continue;
+                }
+
+                while (duration > TimeSpan.Zero)
+                {
+                    var paidBlock = GetPaidBlock();
+                    if (paidBlock != null)
+                    {
+                        if (duration > curPaidBlockDuration)
+                        {
+                            // In this case, the block is more than the rest of
+                            // the current timesheet, so we use up all the remaining timesheet
+                            // time, and then move to the next timesheet.
+                            var newBlock = block.Copy();
+                            newBlock.Start = curTime;
+                            newBlock.Finish = curTime + curPaidBlockDuration;
+                            newBlocks.Add(newBlock);
+
+                            duration -= curPaidBlockDuration;
+                            AdvancePaidBlock();
+                        }
+                        else
+                        {
+                            // Otherwise, we use up the entire block, and decrease the interval by the duration remaining.
+                            block.Start = curTime;
+                            block.Finish = curTime + duration;
+                            newBlocks.Add(block);
+
+                            curPaidBlockDuration -= duration;
+                            curTime += duration;
+                            duration = TimeSpan.Zero;
+
+                            if(curPaidBlockDuration == TimeSpan.Zero)
+                            {
+                                AdvancePaidBlock();
+                            }
+                        }
+                    }
+                    else
+                    {
+                        // Do nothing; we've finished the timesheets, so now we just set all the remaining blocks to zero.
+                        block.Start = curTime;
+                        block.Duration = TimeSpan.Zero;
+                        newBlocks.Add(block);
+                        break;
+                    }
+                }
+            }
+
+            return newBlocks;
+        }
+
+        /// <summary>
+        /// Taking a list of <see cref="Block"/>, chop and extend them to match the bounds of the <paramref name="timeSheet"/>.
+        /// </summary>
+        /// <remarks>
+        /// <paramref name="blocks"/> <b>must</b> be in continuous time, however they need not correspond at all to <paramref name="timeSheet"/> yet;
+        /// that is what this function does.
+        /// </remarks>
+        private static List<Block> MaskBlocksToTimeSheet(DayTimeSheet timeSheet, List<Block> blocks)
+        {
+            var paidBlocks = timeSheet.PaidBlocks.ToList();
+            if(paidBlocks.Count == 0)
+            {
+                // No time to be paid, so all assignments have effective duration of zero.
+                foreach(var block in blocks)
+                {
+                    block.Duration = TimeSpan.Zero;
+                }
+                return blocks;
+            }
+
+            // Fill in gaps in the time sheet with unpaid blocks, so we get a continuous time sheet.
+            var timeSheetBlocks = new List<TimeSheetBlock>();
+            foreach(var block in timeSheet.Blocks)
+            {
+                if(timeSheetBlocks.Count > 0)
+                {
+                    var last = timeSheetBlocks[^1];
+                    if(last.Finish < block.Start)
+                    {
+                        timeSheetBlocks.Add(new TimeSheetBlock
+                        {
+                            IsPaid = false,
+                            Start = last.Finish,
+                            Finish = block.Start
+                        });
+                    }
+                }
+                timeSheetBlocks.Add(block);
+            }
+
+            // Now that the blocks have continuous time, we chop to match the timesheet paid blocks.
+
+            // First, shove all the blocks at the start of the day up to match with the time sheet start.
+            var timeSheetStart = timeSheetBlocks[0].Start;
+            var anyAtStart = false;
+            foreach(var block in blocks)
+            {
+                if(block.Start < timeSheetStart)
+                {
+                    anyAtStart = true;
+                    block.Start = timeSheetStart;
+                    if(block.Finish < timeSheetStart)
+                    {
+                        block.Finish = timeSheetStart;
+                    }
+                }
+                else
+                {
+                    // Once we've found the first block that doesn't begin before the timesheet begins, we can set its time to start
+                    // at the time sheet, unless we already have one at the start.
+                    if (!anyAtStart)
+                    {
+                        block.Start = timeSheetStart;
+                        anyAtStart = true;
+                    }
+                    break;
+                }
+            }
+
+            var curBlockIdx = 0;
+            TimeSheetBlock? GetTimeSheetBlock() => curBlockIdx < timeSheetBlocks.Count ? timeSheetBlocks[curBlockIdx] : null;
+            var curBlockDuration = GetTimeSheetBlock()?.Duration ?? TimeSpan.Zero;
+            var curTime = timeSheetStart;
+            void AdvanceBlock()
+            {
+                ++curBlockIdx;
+                var block = GetTimeSheetBlock();
+                curBlockDuration = block?.Duration ?? TimeSpan.Zero;
+                if(block is not null)
+                {
+                    curTime = block.Start;
+                }
+            }
+
+            var newBlocks = new List<Block>();
+            foreach (var block in blocks)
+            {
+                var duration = block.Duration;
+                if(duration == TimeSpan.Zero)
+                {
+                    newBlocks.Add(block);
+                    continue;
+                }
+
+                while (duration > TimeSpan.Zero)
+                {
+                    var paidBlock = GetTimeSheetBlock();
+                    if (paidBlock != null)
+                    {
+                        if (duration > curBlockDuration)
+                        {
+                            // In this case, the block is more than the rest of
+                            // the current timesheet, so we use up all the remaining timesheet
+                            // time, and then move to the next timesheet.
+                            var newBlock = block.Copy();
+                            newBlock.Start = curTime;
+                            newBlock.Finish = curTime + curBlockDuration;
+                            if (paidBlock.IsPaid)
+                            {
+                                newBlocks.Add(newBlock);
+                            }
+
+                            duration -= curBlockDuration;
+                            AdvanceBlock();
+                        }
+                        else
+                        {
+                            // Otherwise, we use up the entire block, and decrease the interval by the duration remaining.
+                            block.Start = curTime;
+                            block.Finish = curTime + duration;
+                            if (paidBlock.IsPaid)
+                            {
+                                newBlocks.Add(block);
+                            }
+
+                            curTime = block.Finish;
+                            curBlockDuration -= duration;
+                            duration = TimeSpan.Zero;
+
+                            if(curBlockDuration == TimeSpan.Zero)
+                            {
+                                AdvanceBlock();
+                            }
+                        }
+                    }
+                    else
+                    {
+                        // Do nothing; we've finished the timesheets, so now we just set all the remaining blocks to zero.
+                        block.Duration = TimeSpan.Zero;
+                        break;
+                    }
+                }
+            }
+
+            // Extend the last block to the end of the current timesheet block, if we haven't reached the end already.
+            var lastBlock = newBlocks[^1];
+            if(GetTimeSheetBlock() is TimeSheetBlock nextTimeSheetBlock)
+            {
+                lastBlock.Finish = nextTimeSheetBlock.Finish;
+                AdvanceBlock();
+            }
+
+            // Next, if there are still more timesheet blocks, fill them with 'lastBlock' as well.
+            while(GetTimeSheetBlock() is TimeSheetBlock block)
+            {
+                var newBlock = lastBlock.Copy();
+                newBlock.Start = block.Start;
+                newBlock.Finish = block.Finish;
+                AdvanceBlock();
+            }
+
+            return newBlocks;
+        }
+
+        private static List<Block> AdjustTimeBlocksExtend(DayTimeSheet timeSheet, List<Block> blocks)
+        {
+            if(blocks.Count == 0)
+            {
+                return blocks;
+            }
+
+            // First, chop and extend blocks to fill continuous time, from the beginning of the first block to the end of the last block.
+
+            // Block chopping
+            var lastEnd = TimeSpan.Zero;
+            foreach(var block in blocks)
+            {
+                // We know this check is enough to check overlap, since the blocks are in increasing order of their start time.
+                if (lastEnd <= block.Start)
+                {
+                    // This block does not overlap any previous blocks. Do not update duration.
+                    lastEnd = block.Finish;
+                }
+                else if(block.Finish >= lastEnd)
+                {
+                    // This block ends after the block that we are overlapping. Update start.
+                    block.Start = lastEnd;
+                    lastEnd = block.Finish;
+                }
+                else
+                {
+                    // This assignment is entirely contained within the assignment being overlapped.
+                    block.Start = lastEnd;
+                    block.Finish = lastEnd;
+                }
+            }
+
+            // Block extending
+            for(int i = 0; i < blocks.Count - 1; ++i)
+            {
+                var block = blocks[i];
+                var nextBlock = blocks[i + 1];
+                if(block.Finish < nextBlock.Start)
+                {
+                    block.Finish = nextBlock.Start;
+                }
+            }
+
+            // Now that the blocks have continuous time, we chop to match the timesheet paid blocks.
+            return MaskBlocksToTimeSheet(timeSheet, blocks);
+        }
+
+        private static List<Block> AdjustTimeBlocksFillTimeSheet(DayTimeSheet timeSheet, List<Block> blocks)
+        {
+            if(blocks.Count == 0)
+            {
+                return blocks;
+            }
+
+            // First, chop blocks to ensure they don't overlap
+            var lastEnd = TimeSpan.Zero;
+            foreach(var block in blocks)
+            {
+                // We know this check is enough to check overlap, since the blocks are in increasing order of their start time.
+                if (lastEnd <= block.Start)
+                {
+                    // This block does not overlap any previous blocks. Do not update duration.
+                    lastEnd = block.Finish;
+                }
+                else if(block.Finish >= lastEnd)
+                {
+                    // This block ends after the block that we are overlapping. Update start.
+                    block.Start = lastEnd;
+                    lastEnd = block.Finish;
+                }
+                else
+                {
+                    // This assignment is entirely contained within the assignment being overlapped.
+                    block.Start = lastEnd;
+                    block.Finish = lastEnd;
+                }
+            }
+
+            // Fill up remaining gaps in time with transient, "lost time" blocks. For simplicity, so that we don't have to query
+            // the timesheet yet, we just go from the start to the end of the day. 'MaskBlocksToTimeSheet' will take care of the rest.
+            var start = TimeSpan.Zero;
+            var finish = TimeSpan.FromDays(1).Subtract(TimeSpan.FromTicks(1));
+
+            // Fill to start of day.
+            if (blocks[0].Start > start)
+            {
+                blocks.Insert(0, Block.Transient(start, blocks[0].Start));
+            }
+
+            // Fill in gaps between blocks
+            var i = 0;
+            while(i < blocks.Count - 1)
+            {
+                var block = blocks[i];
+                var nextBlock = blocks[i + 1];
+                if(nextBlock.Start > block.Finish)
+                {
+                    blocks.Insert(i + 1, Block.Transient(block.Finish, nextBlock.Start));
+                    i += 2;
+                }
+                else
+                {
+                    i += 1;
+                }
+            }
+
+            // Fill to end of day.
+            if (blocks[^1].Finish < finish)
+            {
+                blocks.Add(Block.Transient(blocks[^1].Finish, finish));
+            }
+            
+            return MaskBlocksToTimeSheet(timeSheet, blocks);
+        }
+
+        public static List<Block> AdjustTimeBlocks(AssignmentCostSettings settings, DayTimeSheet timeSheet, List<Block> blocks)
+        {
+            return settings.Algorithm switch
+            {
+                AssignmentCostFillAlgorithm.Basic => AdjustTimeBlocksBasic(blocks),
+                AssignmentCostFillAlgorithm.Scale => AdjustTimeBlocksScale(timeSheet, blocks),
+                AssignmentCostFillAlgorithm.Extend => AdjustTimeBlocksExtend(timeSheet, blocks),
+                AssignmentCostFillAlgorithm.FillTimeSheet => AdjustTimeBlocksFillTimeSheet(timeSheet, blocks),
+                _ => blocks,
+            };
+        }
+        
+        public static void CheckAssignmentCosts(IStore store, DateTime date, Guid employeeID)
+        {
+            var settings = GetSettings(store);
+
+            if (employeeID == Guid.Empty) return;
+
+            var assignments = store.Provider.Query(
+                Filter<Assignment>.Where(x => x.Date).IsEqualTo(date)
+                    .And(x => x.EmployeeLink.ID).IsEqualTo(employeeID),
+                Columns.Required<Assignment>()
+                    .Add(x => x.ID)
+                    .Add(x => x.Cost)
+                    .Add(x => x.Actual.Start)
+                    .Add(x => x.Actual.Finish)
+                    .Add(x => x.Actual.Duration)
+                    .Add(x => x.Booked.Start)
+                    .Add(x => x.Booked.Finish)
+                    .Add(x => x.Booked.Duration))
+                .ToArray<Assignment>();
+            var timesheets = store.Provider.Query(
+                Filter<TimeSheet>.Where(x => x.Date).IsEqualTo(date)
+                    .And(x => x.EmployeeLink.ID).IsEqualTo(employeeID),
+                Columns.None<TimeSheet>()
+                    .Add(x => x.Approved)
+                    .Add(x => x.Start)
+                    .Add(x => x.Finish)
+                    .Add(x => x.ApprovedStart)
+                    .Add(x => x.ApprovedFinish))
+                .ToArray<TimeSheet>();
+            if (timesheets.Length == 0) return;
+
+            var employee = store.Provider.Query(
+                Filter<Employee>.Where(x => x.ID).IsEqualTo(employeeID),
+                Columns.None<Employee>()
+                    .Add(x => x.RosterStart)
+                    .Add(x => x.HourlyRate))
+                .ToObjects<Employee>().FirstOrDefault();
+            if (employee is null) return;
+
+            var rosterItems = store.Provider.Query(
+                Filter<EmployeeRosterItem>.Where(x => x.Employee.ID).IsEqualTo(employeeID),
+                Columns.None<EmployeeRosterItem>()
+                    .Add(x => x.Overtime.ID),
+                new SortOrder<EmployeeRosterItem>(x => x.Day))
+                .ToArray<EmployeeRosterItem>();
+
+            var overtimeID = RosterUtils.GetRoster(rosterItems, employee.RosterStart, date)?.Overtime.ID ?? Guid.Empty;
+            if(overtimeID == Guid.Empty) return;
+
+            var overtime = store.Provider.Query(
+                Filter<OvertimeInterval>.Where(x => x.Overtime.ID).IsEqualTo(overtimeID),
+                Columns.None<OvertimeInterval>()
+                    .Add(x => x.Interval)
+                    .Add(x => x.IntervalType)
+                    .Add(x => x.Multiplier)
+                    .Add(x => x.IsPaid),
+                new SortOrder<OvertimeInterval>(x => x.Sequence))
+                .ToArray<OvertimeInterval>();
+
+            var script = GetScriptObject(store);
+
+            ScriptBeforeProcess(script, assignments, timesheets);
+
+            // We need to sort the assignments, because both the chopping algorithm and the overtime algorithm requires it.
+            assignments.SortBy(x => x.EffectiveStartTime());
+
+            var blocks = new List<Block>();
+            foreach(var assignment in assignments)
+            {
+                blocks.Add(Block.FromAssignment(assignment));
+            }
+
+            var timeSheet = DayTimeSheet.FromTimeSheets(timesheets, overtime);
+
+            blocks = ScriptAdjustTimeBlocks(script, timeSheet, blocks);
+
+            blocks = AdjustTimeBlocks(settings, timeSheet, blocks);
+
+            blocks = ScriptProcessTimeBlocks(script, timeSheet, blocks);
+
+            var totalAccountingHours = new Dictionary<Assignment, double>();
+            var totalHours = new Dictionary<Assignment, double>();
+            OvertimeUtils.EvaluateOvertime(
+                blocks,
+                // We've already taken into account the IsPaid flags, so here we remove the unpaid ones from the intervals.
+                overtime.Where(x => x.IsPaid).ToArray(),
+                x => x.Duration,
+                (block, interval, duration) =>
+                {
+                    if(block.Assignment is not null)
+                    {
+                        totalAccountingHours[block.Assignment] =
+                            totalAccountingHours.GetValueOrAdd(block.Assignment)
+                            + duration.TotalHours;
+                        totalHours[block.Assignment] =
+                            totalHours.GetValueOrAdd(block.Assignment)
+                            + duration.TotalHours * (interval?.Multiplier ?? 1);
+                    }
+                });
+
+            foreach(var assignment in assignments)
+            {
+                assignment.Cost = totalHours.GetValueOrDefault(assignment) * employee.HourlyRate;
+                assignment.AccountingHours = totalAccountingHours.GetValueOrDefault(assignment);
+            }
+            ScriptAfterOvertime(script, timeSheet, blocks, overtime, assignments);
+
+            store.Provider.Save(assignments.Where(x => x.IsChanged()));
+        }
+    }
+}
+

+ 15 - 100
prs.stores/AssignmentStore.cs

@@ -4,11 +4,23 @@ using Comal.Classes;
 using InABox.Core;
 using System;
 using ExCSS;
+using InABox.Database.Stores;
+using InABox.Database;
+using InABox.Configuration;
+using Comal.Stores;
+using PRSStores.AssignmentCosting;
 
-namespace Comal.Stores
+namespace PRSStores
 {
     internal class AssignmentStore : BaseStore<Assignment>
     {
+        public override void Init()
+        {
+            base.Init();
+
+            GlobalSettingsStore.RegisterSubStore<AssignmentCostSettings, AssignmentCosting.AssignmentCostingUtils>();
+        }
+
         private void CheckActivityForms(Assignment assignment)
         {
             if (!assignment.ActivityLink.HasOriginalValue(x => x.ID))
@@ -84,107 +96,10 @@ namespace Comal.Stores
             }
             if(changedEmployee || changedDate)
             {
-                CheckAssignmentCosts(date, employeeID);
-            }
-
-            CheckAssignmentCosts(entity.Date, entity.EmployeeLink.ID);
-        }
-        
-        private void CheckAssignmentCosts(DateTime date, Guid employeeID)
-        {
-            if (employeeID == Guid.Empty) return;
-
-            var assignments = Provider.Query(
-                Filter<Assignment>.Where(x => x.Date).IsEqualTo(date)
-                    .And(x => x.EmployeeLink.ID).IsEqualTo(employeeID),
-                Columns.Required<Assignment>()
-                    .Add(x => x.ID)
-                    .Add(x => x.Cost)
-                    .Add(x => x.Actual.Start)
-                    .Add(x => x.Actual.Finish)
-                    .Add(x => x.Actual.Duration)
-                    .Add(x => x.Booked.Start)
-                    .Add(x => x.Booked.Finish)
-                    .Add(x => x.Booked.Duration))
-                .ToArray<Assignment>();
-
-            var employee = Provider.Query(
-                Filter<Employee>.Where(x => x.ID).IsEqualTo(employeeID),
-                Columns.None<Employee>()
-                    .Add(x => x.RosterStart)
-                    .Add(x => x.HourlyRate))
-                .ToObjects<Employee>().FirstOrDefault();
-            if (employee is null) return;
-
-            var rosterItems = Provider.Query(
-                Filter<EmployeeRosterItem>.Where(x => x.Employee.ID).IsEqualTo(employeeID),
-                Columns.None<EmployeeRosterItem>()
-                    .Add(x => x.Overtime.ID),
-                new SortOrder<EmployeeRosterItem>(x => x.Day))
-                .ToArray<EmployeeRosterItem>();
-
-            var overtimeID = RosterUtils.GetRoster(rosterItems, employee.RosterStart, date)?.Overtime.ID ?? Guid.Empty;
-            if(overtimeID == Guid.Empty) return;
-
-            var overtime = Provider.Query(
-                Filter<OvertimeInterval>.Where(x => x.Overtime.ID).IsEqualTo(overtimeID),
-                Columns.None<OvertimeInterval>()
-                    .Add(x => x.Interval)
-                    .Add(x => x.IntervalType)
-                    .Add(x => x.Multiplier)
-                    .Add(x => x.IsPaid),
-                new SortOrder<OvertimeInterval>(x => x.Sequence))
-                .ToArray<OvertimeInterval>();
-
-            // We need to sort the assignments, because both the chopping algorithm and the overtime algorithm requires it.
-            assignments.SortBy(x => x.EffectiveStartTime());
-
-            // Do assignment choppage.
-            var lastEnd = TimeSpan.Zero;
-            var durations = assignments.ToArray(assignment =>
-            {
-                var start = assignment.EffectiveStartTime();
-                var finish = assignment.EffectiveFinishTime();
-                // We know this check is enough to check overlap, since the assignments are in increasing order of their start time.
-                if (lastEnd <= start)
-                {
-                    // This assignment does not overlap any previous assignments.
-                    lastEnd = assignment.EffectiveFinishTime();
-                    return finish - start;
-                }
-                else if(finish >= lastEnd)
-                {
-                    // This assignment ends after the assignment that we are overlapping.
-                    var duration = finish - lastEnd;
-                    lastEnd = finish;
-                    return duration;
-                }
-                else
-                {
-                    // This assignment is entirely contained within the assignment being overlapped.
-                    return TimeSpan.Zero;
-                }
-            });
-
-            var totalHours = new double[assignments.Length];
-            OvertimeUtils.EvaluateOvertime(Enumerable.Range(0, assignments.Length), overtime, i => durations[i], (i, interval, duration) =>
-            {
-                if(interval is not null)
-                {
-                    var multiplier = interval.IsPaid ? interval.Multiplier : 0;
-                }
-                if (interval?.IsPaid != false)
-                {
-                    totalHours[i] += duration.TotalHours * (interval?.Multiplier ?? 1);
-                }
-            });
-
-            foreach(var (assignment, hours) in assignments.Zip(totalHours))
-            {
-                assignment.Cost = hours * employee.HourlyRate;
+                AssignmentCostingUtils.CheckAssignmentCosts(this, date, employeeID);
             }
 
-            Provider.Save(assignments.Where(x => x.IsChanged()));
+            AssignmentCostingUtils.CheckAssignmentCosts(this, entity.Date, entity.EmployeeLink.ID);
         }
     }
 }

+ 53 - 0
prs.stores/TimeSheetStore.cs

@@ -0,0 +1,53 @@
+using Comal.Classes;
+using Comal.Stores;
+using InABox.Core;
+using PRSStores.AssignmentCosting;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRSStores;
+
+internal class TimeSheetStore : BaseStore<TimeSheet>
+{
+    protected override void AfterSave(TimeSheet entity)
+    {
+        base.AfterSave(entity);
+
+        UpdateAssignmentCosting(entity);
+    }
+    private void UpdateAssignmentCosting(TimeSheet entity)
+    {
+        if(!entity.HasOriginalValue(x => x.Approved)
+            && !entity.HasOriginalValue(x => x.ApprovedStart)
+            && !entity.HasOriginalValue(x => x.ApprovedFinish)
+            && !entity.HasOriginalValue(x => x.Start)
+            && !entity.HasOriginalValue(x => x.Finish)
+            && !entity.HasOriginalValue(x => x.EmployeeLink.ID)
+            && !entity.HasOriginalValue(x => x.Date))
+        {
+            // No change we care about.
+            return;
+        }
+
+        var changedEmployee = entity.TryGetOriginalValue(x => x.EmployeeLink.ID, out var employeeID);
+        if (!changedEmployee)
+        {
+            employeeID = entity.EmployeeLink.ID;
+        }
+
+        var changedDate = entity.TryGetOriginalValue(x => x.Date, out var date);
+        if (!changedDate)
+        {
+            date = entity.Date;
+        }
+        if(changedEmployee || changedDate)
+        {
+            AssignmentCostingUtils.CheckAssignmentCosts(this, date, employeeID);
+        }
+
+        AssignmentCostingUtils.CheckAssignmentCosts(this, entity.Date, entity.EmployeeLink.ID);
+    }
+}