using Comal.Classes; using CsvHelper; using CsvHelper.Configuration.Attributes; using InABox.Core; using InABox.Core.Postable; using InABox.Poster.Timberline; using InABox.Scripting; using Microsoft.Win32; using PRS.Shared.TimeSheetTimberline; using System.ComponentModel; using System.Globalization; using System.IO; namespace PRS.Shared { namespace TimeSheetTimberline { /// /// Represents a block of time having an . It will always be linked to a timesheet. /// /// /// The primary reason we need a link to a time sheet is so that we can mark the given time sheet as posted if all its corresponding activity blocks /// are posted correctly. /// public class ActivityBlock { public Guid Activity { get; set; } public TimeSpan Start { get; set; } public TimeSpan Finish { get; set; } public TimeSheet TimeSheet { get; set; } public TimeSpan Duration => Finish - Start; /// /// Create an from an , taking the and from /// and . /// /// /// The activity is sourced from the assignment, unless the assignment has no activity, in which case the activity on the timesheet is used. /// public ActivityBlock(Assignment assignment, TimeSheet sheet) { Activity = assignment.ActivityLink.ID != Guid.Empty ? assignment.ActivityLink.ID : sheet.ActivityLink.ID; Start = assignment.EffectiveStartTime(); Finish = assignment.EffectiveFinishTime(); TimeSheet = sheet; } /// /// Creates an from the timesheet, using the and . /// public ActivityBlock(TimeSheet sheet) { Activity = sheet.ActivityLink.ID; Start = sheet.ApprovedStart; Finish = sheet.ApprovedFinish; TimeSheet = sheet; } /// /// Creates an from the timesheet, but with a custom start and finish, /// for use with masking activities. /// public ActivityBlock(TimeSheet sheet, TimeSpan start, TimeSpan finish) { Activity = sheet.ActivityLink.ID; Start = start; Finish = finish; TimeSheet = sheet; } /// /// Ensure that this fits within the bounds of the . /// /// Itself. public ActivityBlock Chop(TimeSheet sheet) { if (Start < sheet.ApprovedStart) { Start = sheet.ApprovedStart; } if (Finish > sheet.ApprovedFinish) { Finish = sheet.ApprovedFinish; } return this; } /// /// Check if this block is partially or fully within the given timesheet. /// public bool ContainedInTimeSheet(TimeSheet sheet) => Start < sheet.ApprovedFinish && Finish > sheet.ApprovedStart; public bool IntersectsWith(ActivityBlock other) { return Start < other.Finish && Finish > other.Start; } } public interface IBlock { IJob Job { get; set; } string Extra { get; set; } string TaskID { get; set; } TimeSpan Duration { get; set; } string PayrollID { get; set; } TimeSheet TimeSheet { get; set; } } public class PaidWorkBlock(string taskID, TimeSpan duration, string payID, IJob job, TimeSheet timeSheet) : IBlock { public IJob Job { get; set; } = job; public string Extra { get; set; } = ""; public string TaskID { get; set; } = taskID; public TimeSpan Duration { get; set; } = duration; public string PayrollID { get; set; } = payID; public TimeSheet TimeSheet { get; set; } = timeSheet; } public class LeaveBlock(string payrollID, TimeSpan duration, TimeSheet timeSheet) : IBlock { public IJob Job { get; set; } = new Job(); public string Extra { get; set; } = ""; public string TaskID { get; set; } = ""; public TimeSpan Duration { get; set; } = duration; public string PayrollID { get; set; } = payrollID; public TimeSheet TimeSheet { get; set; } = timeSheet; } public class BaseArgs(IDataModel model, Guid employee, DateTime date) : CancelEventArgs { public IDataModel Model { get; set; } = model; public Guid Employee { get; set; } = employee; public DateTime Date { get; set; } = date; } public class ProcessRawDataArgs( IDataModel model, Guid employee, DateTime date, List timeSheets, List assignments) : BaseArgs(model, employee, date) { public List TimeSheets { get; set; } = timeSheets; public List Assignments { get; set; } = assignments; } public class ProcessActivityBlocksArgs( IDataModel model, Guid employee, DateTime date, List activityBlocks) : BaseArgs(model, employee, date) { public List ActivityBlocks { get; set; } = activityBlocks; } public class ProcessTimeBlocksArgs( IDataModel model, Guid employee, DateTime date, List blocks) : BaseArgs(model, employee, date) { public List Blocks { get; set; } = blocks; } public class ProcessItemArgs( IDataModel model, Guid employee, DateTime date, IJob job, TimesheetTimberlineItem item) : BaseArgs(model, employee, date) { public TimesheetTimberlineItem Item { get; set; } = item; public IJob Job { get; set; } = job; } } public class TimeSheetTimberlineResult : PostResult { private List items = new List(); public IEnumerable Items => items; public void AddItem(TimesheetTimberlineItem item) { items.Add(item); } public void Sort() { items.Sort((a, b) => { var sort = a.Employee.CompareTo(b.Employee); if (sort != 0) return sort; return a.InDate.CompareTo(b.InDate); }); } } public class TimesheetTimberlineItem { [Index(0)] public string Employee { get; set; } = ""; [Index(1)] [CsvHelper.Configuration.Attributes.TypeConverter(typeof(TimberlinePosterDateConverter))] public DateOnly InDate { get; set; } [Index(2)] public string Job { get; set; } = ""; [Index(3)] public string Extra { get; set; } = ""; [Index(4)] public string Task { get; set; } = ""; [Index(5)] public double Hours { get; set; } [Index(6)] public string PayID { get; set; } = ""; } public enum TimesheetTimberlineActivityCalculation { /// /// Assignments are completely ignored by the export, so that the activities and time is solely provided by the time sheet. /// TimesheetOnly, /// /// Timesheets with activities are processed like in , but for timesheets without activities, the /// assignments are used to generate time blocks, resorting to the timesheet where there is no assignment. /// TimesheetPriority, /// /// Leave timesheets are processed like , but all other timesheets use assignments to generate time blocks, /// resorting to the timesheet when there is no assignment for a block of time. /// AssignmentPriority } public class TimesheetTimberlineSettings : TimberlinePosterSettings { [EnumLookupEditor(typeof(TimesheetTimberlineActivityCalculation), LookupWidth = 200)] public TimesheetTimberlineActivityCalculation ActivityCalculation { get; set; } protected override string DefaultScript() { return @"using PRS.Shared; using PRS.Shared.TimeSheetTimberline; using InABox.Core; using System.Collections.Generic; public class Module { public void BeforePost(IDataModel model) { // Perform pre-processing } public void ProcessRawData(ProcessRawDataArgs args) { // Before PRS calculates anything, you can edit the list of timesheets and assignments it is working with here. } public void ProcessActivityBlocks(ProcessActivityBlocksArgs args) { // Once PRS has aggregated the list of timesheets and assignments into a list of time blocks with given activities, you can edit these time blocks here. } public void ProcessTimeBlocks(ProcessTimeBlocksArgs args) { // This function is called after PRS has determined the length, duration and overtime rules for all the blocks of time. Here, you can edit // this data before it is collated into the export. } public void ProcessItem(ProcessItemArgs args) { // This is the final function before PRS exports each item. You can edit the data as you wish. } public void AfterPost(IDataModel model) { // Perform post-processing } }"; } } public class TimesheetTimberlinePoster : ITimberlinePoster { public ScriptDocument? Script { get; set; } public TimesheetTimberlineSettings Settings { get; set; } private Dictionary _activities = null!; // Initialised on DoProcess() private Dictionary _overtimeIntervals = null!; // Initialised on DoProcess() public bool BeforePost(IDataModel model) { model.RemoveTable("CompanyLogo"); model.RemoveTable("CompanyInformation"); model.RemoveTable(); model.RemoveTable(); model.SetColumns(Columns.None().Add(x => x.ID) .Add(x => x.Approved) .Add(x => x.EmployeeLink.ID) .Add(x => x.EmployeeLink.Code) .Add(x => x.Date) .Add(x => x.ApprovedDuration) .Add(x => x.ApprovedStart) .Add(x => x.ApprovedFinish) .Add(x => x.ActivityLink.ID) .Add(x => x.JobLink.ID) .Add(x => x.JobLink.JobNumber)); // Since the activities could come from the assignment or the time sheets, we'll just // load all the activities, rather than use subquery stuff or multiple tables. model.AddTable( null, Columns.None().Add(x => x.ID).Add(x => x.Code).Add(x => x.PayrollID).Add(x => x.IsLeave), isdefault: true); // Grab every employee on the listed timesheets, that have a PayrollID. model.AddLookupTable(x => x.EmployeeLink.ID, x => x.ID, new Filter(x => x.PayrollID).IsNotEqualTo(""), Columns.None() .Add(x => x.ID) .Add(x => x.Code) .Add(x => x.PayrollID) .Add(x => x.OvertimeRuleLink.ID) .Add(x => x.RosterStart), lookupalias: "Employees", isdefault: true); // We also need to load the rosters and all the overtime intervals on those rosters for // each employee. model.AddChildTable(x => x.ID, x => x.Employee.ID, columns: Columns.None() .Add(x => x.ID) .Add(x => x.Overtime.ID) .Add(x => x.Employee.ID), parentalias: "Employees", childalias: "Rosters", isdefault: true); // Note how we skip the actual Overtime class and just link on the shared link. model.AddChildTable(x => x.Overtime.ID, x => x.Overtime.ID, null, Columns.None().Add(x => x.ID) .Add(x => x.Overtime.ID) .Add(x => x.Sequence) .Add(x => x.IntervalType) .Add(x => x.Interval) .Add(x => x.PayrollID) .Add(x => x.IsPaid), isdefault: true, parentalias: "Rosters"); // Also need every assignment on the same date as the listed timesheets. This will load // more than necessary, since we only care about those for the right employees, but this // is simpler than having a complex sub-query. I guess our data model system doesn't // allow for multiple parent tables. model.AddLookupTable(x => x.Date, x => x.Date, null, Columns.None().Add(x => x.ID) .Add(x => x.Date) .Add(x => x.EmployeeLink.ID) .Add(x => x.Actual.Start) .Add(x => x.Actual.Duration) .Add(x => x.Actual.Finish) .Add(x => x.Booked.Start) .Add(x => x.Booked.Duration) .Add(x => x.Booked.Finish) .Add(x => x.ActivityLink.ID), isdefault: true); Script?.Execute(methodname: "BeforePost", parameters: new object[] { model }); return true; } private void ProcessRawData(ProcessRawDataArgs args) { Script?.Execute(methodname: "ProcessRawData", parameters: new object[] { args }); } private void ProcessActivityBlocks(ProcessActivityBlocksArgs args) { Script?.Execute(methodname: "ProcessActivityBlocks", parameters: new object[] { args }); } private void ProcessTimeBlocks(ProcessTimeBlocksArgs args) { Script?.Execute(methodname: "ProcessTimeBlocks", parameters: new object[] { args }); } private void ProcessItem(ProcessItemArgs args) { Script?.Execute(methodname: "ProcessItem", parameters: new object[] { args }); } /// /// Return a list of s, where every assignment in the given list /// that occurs within the timesheet is given a block of time, and any remaining time is /// filled by the time sheet. /// /// /// /// /// If the time sheet is of type leave, it completely overrides the assignments. /// /// /// If there are any overlapping assignments, their total time is merged and then /// distributed proportionally to each of the overlapping assignments, outputted in /// order by start time. /// /// /// /// /// A list of activity blocks, starting at the beginning of the time sheet and filling /// continuous time to the end of the time sheet. /// private IEnumerable GetMaskedActivityBlocks(IEnumerable assignments, TimeSheet sheet) { // If the time sheet has an activity which is leave, it overrides any assignments. if (sheet.ActivityLink.ID != Guid.Empty && _activities.TryGetValue(sheet.ActivityLink.ID, out var activity) && activity.IsLeave) { yield return new ActivityBlock(sheet); yield break; } // Otherwise, we find every assignment that exists inside this time sheet, and truncate (or "chop") it to fit // inside the time sheet. We also want to order by time. var blocks = assignments.Select(x => new ActivityBlock(x, sheet)) .Where(x => x.ContainedInTimeSheet(sheet)).Select(x => x.Chop(sheet)) .OrderBy(x => x.Start).ToList(); // Redistribute time of overlapping blocks. for(int i = 0; i < blocks.Count; ++i) { var block = blocks[i]; // Total duration of all overlapping blocks. var totalTime = block.Duration; // End time of the block created by merging all overlapping blocks. var maxFinish = block.Finish; // Find all overlapping blocks; j represents the next non-overlapping block. int j = i + 1; for (; j < blocks.Count && block.IntersectsWith(blocks[j]); ++j) { totalTime += blocks[j].Duration; if (blocks[j].Finish > maxFinish) { maxFinish = blocks[j].Finish; } } // Total time of the block created by merging all overlapping blocks. var netTime = maxFinish - block.Start; var start = block.Start; for(int k = i; k < j; ++k) { var newBlock = blocks[k]; var frac = newBlock.Duration.TotalHours / totalTime.TotalHours; var duration = netTime.Multiply(frac); newBlock.Start = start; newBlock.Finish = start + duration; start = newBlock.Finish; } // Note that we don't skip over the blocks that we have re-distributed time to, since any of the blocks after 'i' // may overlap with later blocks that don't overlap with blocks[i]. However, after redistributing, blocks[i] will only // get smaller, not bigger, so blocks[i] definitely won't overlap with later blocks, and so can now be safely skipped. } // Keep track of the current time, which is the end of the last block processed. var curTime = sheet.ApprovedStart; foreach(var block in blocks) { // If there is a gap between the last block and the current block, then we use the // time sheet to create a small activity block filling the gap. if (block.Start > curTime) { yield return new ActivityBlock(sheet, curTime, block.Start); } yield return block; curTime = block.Finish; } // If there is time at the end, also fill that extra time using the time sheet. if(curTime < sheet.ApprovedFinish) { yield return new ActivityBlock(sheet, curTime, sheet.ApprovedFinish); } } /// /// Based on , split each /// timesheet up into a number of activity blocks. Note that time is only allocated /// where a time sheet is, so that there will be no ActivityBlocks with time outside of the /// time represented by the list of time sheets. /// /// /// The output will have continuous activity blocks filling all the time of each time sheet. ///
/// Note that if the timesheets themselves overlap, no special functionality exists, and /// there will in this case be overlapping time blocks. ///
/// A list of assignments for this employee and date. /// A list of timesheets for this employee and date. /// A list of blocks of time that represent an activity the employee was doing. private List GetActivityBlocks(IEnumerable assignments, IList sheets) { switch (Settings.ActivityCalculation) { case TimesheetTimberlineActivityCalculation.TimesheetOnly: // In this case, we ignore 'assignments' entirely and just the time sheet constructor for the blocks. return sheets.Select(x => new ActivityBlock(x)).OrderBy(x => x.Start).ToList(); case TimesheetTimberlineActivityCalculation.TimesheetPriority: var sheetLookup = sheets.ToLookup(x => x.ActivityLink.ID == Guid.Empty); // Every timesheet that has an activity is a valid activity block. Then, for // each timesheet without an activity, we merge the assignments into the time sheet. return sheetLookup[false].Select(x => new ActivityBlock(x)) .Concat(sheetLookup[true].SelectMany(x => GetMaskedActivityBlocks(assignments, x))) .OrderBy(x => x.Start) .ToList(); case TimesheetTimberlineActivityCalculation.AssignmentPriority: // Every timesheet is masked, unless it is a leave timesheet. return sheets.SelectMany(x => GetMaskedActivityBlocks(assignments, x)).OrderBy(x => x.Start).ToList(); default: throw new Exception($"Invalid Activity calculation {Settings.ActivityCalculation}"); } } /// /// Take a list of paid work blocks, and create a new list of paid work blocks by assigning /// PayrollIDs based on the overtime intervals. If a given interval is unpaid, then no paid /// work blocks are created for that time. /// private List EvaluateOvertime(IEnumerable time, Guid overtimeID) { var overtimeIntervals = _overtimeIntervals.GetValueOrDefault(overtimeID)?.ToArray() ?? []; var curOvertimeIdx = 0; OvertimeInterval? GetOvertimeInterval() => curOvertimeIdx < overtimeIntervals.Length ? overtimeIntervals[curOvertimeIdx] : null; var curInterval = GetOvertimeInterval()?.Interval ?? TimeSpan.Zero; var newItems = new List(); foreach (var block in time) { var duration = block.Duration; while (duration > TimeSpan.Zero) { var interval = GetOvertimeInterval(); if (interval != null) { switch (interval.IntervalType) { case OvertimeIntervalType.Interval: if (duration >= curInterval) { // In this case, the paid work block is more than the rest of // the current interval, so we use up all the remaining interval // time, and then move to the next interval. if (interval.IsPaid) { if(block is PaidWorkBlock paid) { newItems.Add(new PaidWorkBlock(block.TaskID, curInterval, interval.PayrollID, block.Job, block.TimeSheet)); } else if(block is LeaveBlock leave) { newItems.Add(new LeaveBlock(leave.PayrollID, curInterval, leave.TimeSheet)); } } duration -= curInterval; ++curOvertimeIdx; curInterval = GetOvertimeInterval()?.Interval ?? TimeSpan.Zero; } else { // Otherwise, we use up the entire paid work block, and decrease the interval by the duration remaining. if (interval.IsPaid) { if(block is PaidWorkBlock paid) { newItems.Add(new PaidWorkBlock(block.TaskID, duration, interval.PayrollID, block.Job, block.TimeSheet)); } else if(block is LeaveBlock leave) { newItems.Add(new LeaveBlock(leave.PayrollID, duration, leave.TimeSheet)); } } curInterval -= duration; duration = TimeSpan.Zero; } break; case OvertimeIntervalType.RemainingTime: // In this case, the interval is unchanged. if (interval.IsPaid) { if(block is PaidWorkBlock paid) { newItems.Add(new PaidWorkBlock(block.TaskID, duration, interval.PayrollID, block.Job, block.TimeSheet)); } else if(block is LeaveBlock leave) { newItems.Add(new LeaveBlock(leave.PayrollID, duration, leave.TimeSheet)); } } duration = TimeSpan.Zero; break; default: throw new NotImplementedException($"Not implemented Overtime interval type {interval.IntervalType}"); } } else { // If there is no overtime interval, then we use up the rest of the time on // the block with a blank PayrollID. Theoretically, this shouldn't happen, // since the "RemainingTime" interval is required. if(block is PaidWorkBlock paid) { newItems.Add(new PaidWorkBlock(block.TaskID, duration, "", block.Job, block.TimeSheet)); } else if(block is LeaveBlock leave) { newItems.Add(new LeaveBlock(leave.PayrollID, duration, leave.TimeSheet)); } duration = TimeSpan.Zero; } } } return newItems; } private TimeSheetTimberlineResult DoProcess(IDataModel model) { var items = new TimeSheetTimberlineResult(); var timesheets = model.GetTable().ToObjects().ToList(); if(timesheets.Any(x => x.Approved.IsEmpty())) { throw new Exception("Unapproved Timesheets detected"); } else if (!timesheets.Any()) { throw new Exception("No approved timesheets found"); } _activities = model.GetTable().ToObjects().ToDictionary(x => x.ID, x => x); _overtimeIntervals = model.GetTable().ToObjects() .GroupBy(x => x.Overtime.ID) .ToDictionary(x => x.Key, x => x.OrderBy(x => x.Sequence).ToArray()); var rosters = model.GetTable("Rosters").ToObjects() .GroupBy(x => x.Employee.ID).ToDictionary(x => x.Key, x => x.ToArray()); var employees = model.GetTable("Employees").ToObjects() .ToDictionary(x => x.ID, x => x); // We run through by date and employee, grouping both assignments and time sheets. var assignments = model.GetTable().ToObjects() .GroupBy(x => new { x.Date, Employee = x.EmployeeLink.ID }).ToDictionary(x => x.Key, x => x.ToList()); var daily = timesheets.GroupBy(x => new { x.Date, Employee = x.EmployeeLink.ID }).ToDictionary(x => x.Key, x => x.ToList()); foreach(var (key, sheets) in daily) { var dateAssignments = assignments.GetValueOrDefault(new { key.Date, key.Employee }, new List()); // Delegate to script to process raw data. var rawArgs = new ProcessRawDataArgs(model, key.Employee, key.Date, sheets, dateAssignments); ProcessRawData(rawArgs); if (rawArgs.Cancel) { foreach(var sheet in sheets) { items.AddFailed(sheet, "Post cancelled by script."); } continue; } // Split each timesheet into its activity blocks. var activityBlocks = GetActivityBlocks(rawArgs.Assignments, rawArgs.TimeSheets); // Process activity blocks with script. var activityArgs = new ProcessActivityBlocksArgs(model, key.Employee, key.Date, activityBlocks); ProcessActivityBlocks(activityArgs); if (activityArgs.Cancel) { foreach (var sheet in sheets) { items.AddFailed(sheet, "Post cancelled by script."); } continue; } // Add up all the time for the time sheets. var approvedDuration = rawArgs.TimeSheets.Aggregate(TimeSpan.Zero, (x, y) => x + y.ApprovedDuration); if(approvedDuration == TimeSpan.Zero) { foreach (var sheet in sheets) { items.AddFailed(sheet, "Zero Approved Duration"); } continue; } // Convert the activity blocks into LeaveBlocks and PaidWorkBlocks, based on the activity on the block. var blocks = new List(); foreach (var block in activityArgs.ActivityBlocks) { string taskID; bool isLeave; if (block.Activity == Guid.Empty || !_activities.TryGetValue(block.Activity, out var activity)) { if(block.Activity != Guid.Empty) { Logger.Send(LogType.Error, "", $"Error in Timesheet Timberline export: Activity {block.Activity} does not exist!"); } taskID = ""; isLeave = false; } else { isLeave = activity.IsLeave; taskID = activity.PayrollID; } if (isLeave) { blocks.Add(new LeaveBlock(taskID, block.Finish - block.Start, block.TimeSheet)); } else { // Leave PayID blank until we've worked out the rosters blocks.Add(new PaidWorkBlock(taskID, block.Finish - block.Start, "", block.TimeSheet.JobLink, block.TimeSheet)); } } // Find the roster data. var employee = employees.GetValueOrDefault(key.Employee); var employeeRosters = rosters.GetValueOrDefault(employee != null ? employee.ID : Guid.Empty); var overtimeID = RosterUtils.GetRoster(employeeRosters, employee?.RosterStart, key.Date)?.Overtime.ID ?? Guid.Empty; // Split up the paid work blocks by the overtime and assign PayrollIDs. var blockItems = EvaluateOvertime(blocks, overtimeID); var blockArgs = new ProcessTimeBlocksArgs(model, key.Employee, key.Date, blockItems); ProcessTimeBlocks(blockArgs); if (blockArgs.Cancel) { foreach (var sheet in sheets) { items.AddFailed(sheet, "Post cancelled by script."); } continue; } // First presumptively succeed all sheets, and then fail them if any of their blocks are failed. foreach (var sheet in sheets) { items.AddSuccess(sheet); } var newItems = new List>>(); // Group the blocks by job, TaskID (activity PayrollID) and PayrollID (from the overtime interval). foreach(var group in blockItems.GroupBy(x => new { x.Job.ID, x.TaskID, x.PayrollID })) { var block = group.ToArray(); var first = block[0]; var item = new TimesheetTimberlineItem { Employee = employee?.PayrollID ?? "", InDate = DateOnly.FromDateTime(key.Date), Job = first.Job.JobNumber, Extra = "", Task = group.Key.TaskID, Hours = Math.Round(group.Sum(x => x.Duration.TotalHours), 2), PayID = group.Key.PayrollID }; var itemArgs = new ProcessItemArgs(model, key.Employee, key.Date, first.Job, item); ProcessItem(itemArgs); var blockTimeSheets = block.Select(x => x.TimeSheet).ToList(); if (!itemArgs.Cancel) { newItems.Add(new(itemArgs.Item, blockTimeSheets)); } else { foreach(var sheet in blockTimeSheets) { (sheet as IPostable).FailPost("Post cancelled by script."); } } } foreach(var item in newItems) { if(item.Item2.All(x => x.PostedStatus == PostedStatus.Posted)) { items.AddItem(item.Item1); } } } items.Sort(); return items; } public IPostResult Process(IDataModel model) { var items = DoProcess(model); var dlg = new SaveFileDialog() { Filter = "CSV Files (*.csv)|*.csv" }; if (dlg.ShowDialog() == true) { using var writer = new StreamWriter(dlg.FileName); using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); foreach (var item in items.Items) { csv.WriteRecord(item); csv.NextRecord(); } return items; } else { throw new PostCancelledException(); } } public void AfterPost(IDataModel model, IPostResult result) { Script?.Execute(methodname: "AfterPost", parameters: new object[] { model }); } } public class TimesheetTimberlinePosterEngine : TimberlinePosterEngine { } }