|
|
@@ -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()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|