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 Blocks { get; private init; } public IEnumerable PaidBlocks => Blocks.Where(x => x.IsPaid); public TimeSpan PaidDuration => PaidBlocks.Aggregate(TimeSpan.Zero, (a, b) => a + b.Duration); private DayTimeSheet(IList blocks) { Blocks = blocks; } /// /// Create a new from a given list of s. The is /// used to create blocks for unpaid time on the timesheet. /// /// If there are no elements in public static DayTimeSheet FromTimeSheets(IEnumerable sheets, IList 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? 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(); 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(); 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 blocks) { public DayTimeSheet TimeSheet { get; } = timeSheet; public List Blocks { get; set; } = blocks; } public class ProcessTimeBlocksArgs(DayTimeSheet timeSheet, List blocks) { public DayTimeSheet TimeSheet { get; } = timeSheet; public List Blocks { get; set; } = blocks; } public class AfterOvertimeArgs(DayTimeSheet timeSheet, List blocks, OvertimeInterval[] overtime, Assignment[] assignments) { public DayTimeSheet TimeSheet { get; } = timeSheet; public List 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 { static AssignmentCostSettings? _costSettings; public static AssignmentCostSettings GetSettings(IStore store) { _costSettings ??= new GlobalConfiguration("", new DbConfigurationProvider(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 ScriptAdjustTimeBlocks(IAssignmentCostingScript? script, DayTimeSheet timeSheet, List blocks) { if(script is not null) { var args = new AdjustTimeBlocksArgs(timeSheet, blocks); script.AdjustTimeBlocks(args); return args.Blocks; } else { return blocks; } } private static List ScriptProcessTimeBlocks(IAssignmentCostingScript? script, DayTimeSheet timeSheet, List 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 blocks, OvertimeInterval[] overtime, Assignment[] assignments) { script?.AfterOvertime(new(timeSheet, blocks, overtime, assignments)); } #endregion private static List AdjustTimeBlocksBasic(List blocks) { // No change for basic mode. return blocks; } private static List AdjustTimeBlocksScale(DayTimeSheet timeSheet, List 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(); 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; } /// /// Taking a list of , chop and extend them to match the bounds of the . /// /// /// must be in continuous time, however they need not correspond at all to yet; /// that is what this function does. /// private static List MaskBlocksToTimeSheet(DayTimeSheet timeSheet, List 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(); 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(); 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 AdjustTimeBlocksExtend(DayTimeSheet timeSheet, List 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 AdjustTimeBlocksFillTimeSheet(DayTimeSheet timeSheet, List 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 AdjustTimeBlocks(AssignmentCostSettings settings, DayTimeSheet timeSheet, List 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 assignmentTask = Task.Run(() => store.Provider.Query( Filter.Where(x => x.Date).IsEqualTo(date) .And(x => x.EmployeeLink.ID).IsEqualTo(employeeID), Columns.Required() .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()); var timesheetTask = Task.Run(() => store.Provider.Query( Filter.Where(x => x.Date).IsEqualTo(date) .And(x => x.EmployeeLink.ID).IsEqualTo(employeeID), Columns.None() .Add(x => x.Approved) .Add(x => x.Start) .Add(x => x.Finish) .Add(x => x.ApprovedStart) .Add(x => x.ApprovedFinish)) .ToArray()); var employeeTask = Task.Run(() => store.Provider.Query( Filter.Where(x => x.ID).IsEqualTo(employeeID), Columns.None() .Add(x => x.RosterStart) .Add(x => x.HourlyRate)) .ToObjects().FirstOrDefault()); var rosterItemsTask = Task.Run(() => store.Provider.Query( Filter.Where(x => x.Employee.ID).IsEqualTo(employeeID), Columns.None() .Add(x => x.Overtime.ID), new SortOrder(x => x.Day)) .ToArray()); var timesheets = timesheetTask.Result; var employee = employeeTask.Result; if (timesheets.Length == 0 || employee is null) return; var assignments = assignmentTask.Result; var rosterItems = rosterItemsTask.Result; var overtimeID = RosterUtils.GetRoster(rosterItems, employee.RosterStart, date)?.Overtime.ID ?? Guid.Empty; if(overtimeID == Guid.Empty) return; var overtime = store.Provider.Query( Filter.Where(x => x.Overtime.ID).IsEqualTo(overtimeID), Columns.None() .Add(x => x.Interval) .Add(x => x.IntervalType) .Add(x => x.Multiplier) .Add(x => x.IsPaid), new SortOrder(x => x.Sequence)) .ToArray(); 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(); 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(); var totalHours = new Dictionary(); 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.CostedHours = totalAccountingHours.GetValueOrDefault(assignment); } ScriptAfterOvertime(script, timeSheet, blocks, overtime, assignments); store.Provider.Save(assignments.Where(x => x.IsChanged())); } } }