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; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows.Input; namespace PRS.Shared { namespace TimeSheetTimberline { 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; 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; } public ActivityBlock(TimeSheet sheet) { Activity = sheet.ActivityLink.ID; Start = sheet.ApprovedStart; Finish = sheet.ApprovedFinish; TimeSheet = sheet; } public ActivityBlock(TimeSheet sheet, TimeSpan start, TimeSpan finish) { Activity = sheet.ActivityLink.ID; Start = start; Finish = finish; TimeSheet = sheet; } public ActivityBlock Chop(TimeSheet sheet) { if (Start < sheet.ApprovedStart) { Start = sheet.ApprovedStart; } if (Finish > sheet.ApprovedFinish) { Finish = sheet.ApprovedFinish; } return this; } 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 { string 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 : IBlock { public string Job { get; set; } public string Extra { get; set; } public string TaskID { get; set; } public TimeSpan Duration { get; set; } public string PayrollID { get; set; } public TimeSheet TimeSheet { get; set; } public PaidWorkBlock(string taskID, TimeSpan duration, string payID, string job, TimeSheet timeSheet) { TaskID = taskID; Duration = duration; PayrollID = payID; Job = job; Extra = ""; TimeSheet = timeSheet; } } public class LeaveBlock : IBlock { public string Job { get; set; } public string Extra { get; set; } public string TaskID { get; set; } public TimeSpan Duration { get; set; } public string PayrollID { get; set; } public TimeSheet TimeSheet { get; set; } public LeaveBlock(string payrollID, TimeSpan duration, TimeSheet timeSheet) { PayrollID = payrollID; Duration = duration; Job = ""; Extra = ""; TaskID = ""; TimeSheet = timeSheet; } } public class BaseArgs : CancelEventArgs { public IDataModel Model { get; set; } public Guid Employee { get; set; } public DateTime Date { get; set; } public BaseArgs(IDataModel model, Guid employee, DateTime date) { Model = model; Employee = employee; Date = date; } } public class ProcessRawDataArgs : BaseArgs { public List TimeSheets { get; set; } public List Assignments { get; set; } public ProcessRawDataArgs( IDataModel model, Guid employee, DateTime date, List timeSheets, List assignments): base(model, employee, date) { TimeSheets = timeSheets; Assignments = assignments; } } public class ProcessActivityBlocksArgs : BaseArgs { public List ActivityBlocks { get; set; } public ProcessActivityBlocksArgs( IDataModel model, Guid employee, DateTime date, List activityBlocks) : base(model, employee, date) { ActivityBlocks = activityBlocks; } } public class ProcessTimeBlocksArgs : BaseArgs { public List WorkBlocks { get; set; } public List LeaveBlocks { get; set; } public ProcessTimeBlocksArgs( IDataModel model, Guid employee, DateTime date, List workBlocks, List leaveBlocks) : base(model, employee, date) { WorkBlocks = workBlocks; LeaveBlocks = leaveBlocks; } } public class ProcessItemArgs : BaseArgs { public TimesheetTimberlineItem Item { get; set; } public ProcessItemArgs( IDataModel model, Guid employee, DateTime date, TimesheetTimberlineItem item) : base(model, employee, date) { Item = item; } } } public class TimeSheetTimberlineResult : PostResult { private List items = new List(); public IEnumerable Items => items; public void AddItem(TimesheetTimberlineItem item) { items.Add(item); } } 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 { TimesheetOnly, TimesheetPriority, 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(new Columns(x => x.ID) .Add(x => x.Approved) .Add(x => x.EmployeeLink.ID) .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.JobNumber)); model.AddTable( null, new Columns(x => x.ID).Add(x => x.Code).Add(x => x.PayrollID).Add(x => x.IsLeave), isdefault: true); model.AddTable( null, new Columns(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); model.AddLookupTable(x => x.EmployeeLink.ID, x => x.ID, new Filter(x => x.PayrollID).IsNotEqualTo(""), new Columns(x => x.ID).Add(x => x.Code).Add(x => x.PayrollID).Add(x => x.OvertimeRuleLink.ID) .Add(x => x.RosterStart), lookupalias: "Employees", isdefault: true); model.AddChildTable(x => x.ID, x => x.Employee.ID, columns: new Columns(x => x.ID) .Add(x => x.Overtime.ID) .Add(x => x.Employee.ID), parentalias: "Employees", childalias: "Rosters", isdefault: true); model.AddLookupTable(x => x.Date, x => x.Date, null, new Columns(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 }); } private IEnumerable GetMaskedActivityBlocks(IEnumerable assignments, TimeSheet sheet) { if (sheet.ActivityLink.ID != Guid.Empty && _activities.TryGetValue(sheet.ActivityLink.ID, out var activity) && activity.IsLeave) { yield return new ActivityBlock(sheet); yield break; } var blocks = assignments.Select(x => new ActivityBlock(x, sheet)) .Where(x => x.ContainedInTimeSheet(sheet)).Select(x => x.Chop(sheet)) .OrderBy(x => x.Start).ToList(); for(int i = 0; i < blocks.Count; ++i) { var block = blocks[i]; var totalTime = block.Duration; 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; } } var netTime = maxFinish - block.Start; var start = block.Start; foreach(var newBlock in blocks.Skip(i).Take(j - i)) { var frac = newBlock.Duration.TotalHours / totalTime.TotalHours; var duration = netTime.Multiply(frac); newBlock.Start = start; newBlock.Finish = start + duration; start = newBlock.Finish; } } var curTime = sheet.ApprovedStart; foreach(var block in blocks) { if (block.Start > curTime) { yield return new ActivityBlock(sheet, curTime, block.Start); } yield return block; curTime = block.Finish; } if(curTime < sheet.ApprovedFinish) { yield return new ActivityBlock(sheet, curTime, sheet.ApprovedFinish); } } private List GetActivityBlocks(IEnumerable assignments, IList sheets) { switch (Settings.ActivityCalculation) { case TimesheetTimberlineActivityCalculation.TimesheetOnly: 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); return sheetLookup[false].Select(x => new ActivityBlock(x)) .Concat(sheetLookup[true].SelectMany(x => GetMaskedActivityBlocks(assignments, x))) .OrderBy(x => x.Start) .ToList(); case TimesheetTimberlineActivityCalculation.AssignmentPriority: return sheets.SelectMany(x => GetMaskedActivityBlocks(assignments, x)).OrderBy(x => x.Start).ToList(); default: throw new Exception($"Invalide Activity calculation {Settings.ActivityCalculation}"); } } private List EvaluateOvertime(IEnumerable workTime, Guid overtimeID) { var overtimeIntervals = _overtimeIntervals.GetValueOrDefault(overtimeID)?.ToList() ?? new List(); overtimeIntervals.Reverse(); var workItems = new List(); foreach (var block in workTime) { var duration = block.Duration; while (duration > TimeSpan.Zero) { var interval = overtimeIntervals.LastOrDefault(); if (interval != null) { switch (interval.IntervalType) { case OvertimeIntervalType.Interval: if (duration >= interval.Interval) { if (interval.IsPaid) { workItems.Add(new(block.TaskID, interval.Interval, interval.PayrollID, block.Job, block.TimeSheet)); } overtimeIntervals.RemoveAt(overtimeIntervals.Count - 1); duration -= interval.Interval; } else { if (interval.IsPaid) { workItems.Add(new(block.TaskID, duration, interval.PayrollID, block.Job, block.TimeSheet)); } interval.Interval -= duration; duration = TimeSpan.Zero; } break; case OvertimeIntervalType.RemainingTime: if (interval.IsPaid) { workItems.Add(new(block.TaskID, duration, interval.PayrollID, block.Job, block.TimeSheet)); } duration = TimeSpan.Zero; break; default: throw new NotImplementedException($"Not implemented Overtime interval type {interval.IntervalType}"); } } else { workItems.Add(new(block.TaskID, duration, "", block.Job, block.TimeSheet)); duration = TimeSpan.Zero; } } } return workItems; } 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); 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()); 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; } var activityBlocks = GetActivityBlocks(rawArgs.Assignments, rawArgs.TimeSheets); 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; } var approvedDuration = rawArgs.TimeSheets.Aggregate(TimeSpan.Zero, (x, y) => x + y.ApprovedDuration); var leave = new List(); var workTime = new List(); foreach (var block in activityArgs.ActivityBlocks) { string payID; 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!"); } payID = ""; isLeave = false; } else { isLeave = activity.IsLeave; payID = activity.PayrollID; } if (isLeave) { leave.Add(new(payID, block.Finish - block.Start, block.TimeSheet)); } else { // Leave PayID blank until we've worked out the rosters workTime.Add(new(payID, block.Finish - block.Start, "", block.TimeSheet.JobLink.JobNumber, block.TimeSheet)); } } if (approvedDuration > TimeSpan.Zero) { 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; var workItems = EvaluateOvertime(workTime, overtimeID); var blockArgs = new ProcessTimeBlocksArgs(model, key.Employee, key.Date, workItems, leave); ProcessTimeBlocks(blockArgs); if (blockArgs.Cancel) { foreach (var sheet in sheets) { items.AddFailed(sheet, "Post cancelled by script."); } continue; } // Succeed all sheets, and then fail them if any of their blocks are failed. foreach (var sheet in sheets) { items.AddSuccess(sheet); } var blocks = (blockArgs.WorkBlocks as IEnumerable).Concat(blockArgs.LeaveBlocks); var newItems = new List>>(); foreach(var block in blocks.GroupBy(x => new { x.Job, x.TaskID, x.PayrollID }, x => x)) { var item = new TimesheetTimberlineItem { Employee = employee?.PayrollID ?? "", InDate = DateOnly.FromDateTime(key.Date), Job = block.Key.Job, Extra = "", Task = block.Key.TaskID, Hours = block.Sum(x => x.Duration.TotalHours), PayID = block.Key.PayrollID }; var itemArgs = new ProcessItemArgs(model, key.Employee, key.Date, 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); } } } else { foreach (var sheet in sheets) { items.AddFailed(sheet, "Zero Approved Duration"); } } } 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 { } }