Browse Source

Multiplier for OvertimeInterval defaults to 1.0.
Obsoleted TimeSheet.Processed
Added validation for OvertimeIntervals

Kenric Nugteren 1 year ago
parent
commit
498bc6862b

+ 2 - 1
prs.classes/Entities/Overtime/OvertimeInterval.cs

@@ -20,7 +20,7 @@ namespace Comal.Classes
         [EditorSequence(1)]
         public string Description { get; set; }
 
-        [EnumLookupEditor(typeof(OvertimeIntervalType))]
+        [EnumLookupEditor(typeof(OvertimeIntervalType), Visible = Visible.Default)]
         [EditorSequence(2)]
         public OvertimeIntervalType IntervalType { get; set; }
 
@@ -49,6 +49,7 @@ namespace Comal.Classes
             Description = "";
             PayrollID = "";
             IsPaid = true;
+            Multiplier = 1.0;
             IntervalType = OvertimeIntervalType.Interval;
         }
     }

+ 8 - 5
prs.classes/Entities/Timesheet/Timesheet.cs

@@ -62,9 +62,11 @@ namespace Comal.Classes
         public DateTime Approved { get; set; }
 
         [SecondaryIndex]
-        [TimestampEditor]
-        [EditorSequence(12)]
-        [LoggableProperty(Format = "dd MMM yy HH:mm")]
+        //[TimestampEditor]
+        //[EditorSequence(12)]
+        //[LoggableProperty(Format = "dd MMM yy HH:mm")]
+        [NullEditor]
+        [Obsolete("Replaced with Posted")]
         public DateTime Processed { get; set; }
         
         [NullEditor]
@@ -100,8 +102,9 @@ namespace Comal.Classes
         [TextBoxEditor(Editable = Editable.Hidden)]
         public string Gate { get; set; }
 
-        [NullEditor]
-        [LoggableProperty]
+        [LoggableProperty(Format = "dd MMM yy HH:mm")]
+        [TimestampEditor]
+        [EditorSequence(12)]
         public DateTime Posted { get; set; }
 
         [NullEditor]

+ 18 - 0
prs.desktop/Grids/OvertimeGrid.cs

@@ -0,0 +1,18 @@
+using Comal.Classes;
+using InABox.DynamicGrid;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRSDesktop
+{
+    public class OvertimeGrid : DynamicDataGrid<Overtime>
+    {
+        protected override void DoValidate(Overtime[] items, List<string> errors)
+        {
+            base.DoValidate(items, errors);
+        }
+    }
+}

+ 47 - 0
prs.desktop/Grids/OvertimeIntervalGrid.cs

@@ -0,0 +1,47 @@
+using Comal.Classes;
+using InABox.DynamicGrid;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PRSDesktop.Grids
+{
+    public class OvertimeIntervalGrid : DynamicOneToManyGrid<Overtime, OvertimeInterval>
+    {
+        protected override void Init()
+        {
+            base.Init();
+
+            HiddenColumns.Add(x => x.IntervalType);
+        }
+
+        protected override void DoReconfigureEditors(DynamicEditorGrid grid, OvertimeInterval[] items)
+        {
+            base.DoReconfigureEditors(grid, items);
+
+            var intervalEditor = grid.FindEditor(nameof(OvertimeInterval.Interval));
+            intervalEditor?.SetEnabled(!items.Any(x => x.IntervalType == OvertimeIntervalType.RemainingTime));
+        }
+
+        // EditorPage BeforeSave
+        public override void BeforeSave(object item)
+        {
+            base.BeforeSave(item);
+
+            if (Items.Count == 0)
+            {
+                throw new Exception("There must be at least one overtime interval.");
+            }
+            else if (Items.SkipLast(1).Any(x => x.IntervalType == OvertimeIntervalType.RemainingTime))
+            {
+                throw new Exception($"Only the last interval may have [IntervalType] = '{OvertimeIntervalType.RemainingTime}'");
+            }
+            else if (Items.Last().IntervalType != OvertimeIntervalType.RemainingTime)
+            {
+                throw new Exception($"The last overtime interval must have [IntervalType] = '{OvertimeIntervalType.RemainingTime}'");
+            }
+        }
+    }
+}

+ 10 - 12
prs.desktop/Grids/TimesheetGrid.cs

@@ -17,7 +17,7 @@ namespace PRSDesktop
     {
         private readonly BitmapImage leave = PRSDesktop.Resources.leave.AsBitmapImage();
 
-        private LeaveRequestGrid leavegrid;
+        private LeaveRequestGrid? leavegrid;
 
         private readonly BitmapImage post = PRSDesktop.Resources.post.AsBitmapImage();
         private readonly BitmapImage tick = PRSDesktop.Resources.tick.AsBitmapImage();
@@ -41,7 +41,7 @@ namespace PRSDesktop
 
             //ActionColumns.Add(new DynamicImageColumn() { Image = WarningImage });
             ActionColumns.Add(new DynamicTickColumn<TimeSheet, DateTime>(x => x.Approved, tick, tick, null));
-            ActionColumns.Add(new DynamicTickColumn<TimeSheet, DateTime>(x => x.Processed, post, tick, null));
+            //ActionColumns.Add(new DynamicTickColumn<TimeSheet, DateTime>(x => x.Processed, post, tick, null));
 
             ActionColumns.Add(new DynamicImageColumn(Posted_Image, null)
             {
@@ -183,7 +183,7 @@ namespace PRSDesktop
             var updates = new List<TimeSheet>();
 
             var lastemp = Guid.Empty;
-            Employee emp = null;
+            Employee? emp = null;
             foreach (var day in employeedays)
             {
                 var date = day.Item2;
@@ -351,12 +351,11 @@ namespace PRSDesktop
             var leave = new LeaveRequest();
             leave.From = DateTime.Today;
             leave.To = DateTime.Today;
-            if (leavegrid == null)
-                leavegrid = new LeaveRequestGrid();
+            leavegrid ??= new LeaveRequestGrid();
             return leavegrid.EditItems(new[] { leave });
         }
 
-        public override bool EditItems(TimeSheet[] items, Func<Type, CoreTable> PageDataHandler = null, bool PreloadPages = false)
+        public override bool EditItems(TimeSheet[] items, Func<Type, CoreTable>? PageDataHandler = null, bool PreloadPages = false)
         {
             var leaveids = items.Select(x => x.LeaveRequestLink.ID).Distinct().ToArray();
             if (leaveids.Length > 1 && leaveids.Contains(Guid.Empty))
@@ -374,12 +373,11 @@ namespace PRSDesktop
                     return false;
                 }
 
-                LeaveRequest[] leaves = null;
+                LeaveRequest[] leaves;
                 using (new WaitCursor())
                 {
                     leaves = new Client<LeaveRequest>().Load(new Filter<LeaveRequest>(x => x.ID).InList(leaveids));
-                    if (leavegrid == null)
-                        leavegrid = new LeaveRequestGrid();
+                    leavegrid ??= new LeaveRequestGrid();
                 }
 
                 return leavegrid.EditItems(leaves);
@@ -388,7 +386,7 @@ namespace PRSDesktop
             return base.EditItems(items, PageDataHandler, PreloadPages);
         }
 
-        protected override Dictionary<string, object> EditorValueChanged(IDynamicEditorForm editor, TimeSheet[] items, string name, object value)
+        protected override Dictionary<string, object?> EditorValueChanged(IDynamicEditorForm editor, TimeSheet[] items, string name, object value)
         {
             var result = base.EditorValueChanged(editor, items, name, value);
 
@@ -456,8 +454,8 @@ namespace PRSDesktop
         //    return false;
         //}
 
-        protected override void Reload(Filters<TimeSheet> criteria, Columns<TimeSheet> columns, ref SortOrder<TimeSheet> sort,
-            Action<CoreTable, Exception> action)
+        protected override void Reload(Filters<TimeSheet> criteria, Columns<TimeSheet> columns, ref SortOrder<TimeSheet>? sort,
+            Action<CoreTable?, Exception?> action)
         {
             var filter = new Filter<TimeSheet>(x => x.Processed).IsEqualTo(DateTime.MinValue);
             if (!string.IsNullOrWhiteSpace(Search))

+ 120 - 39
prs.shared/Posters/Timberline/TimesheetTimberlinePoster.cs

@@ -42,10 +42,17 @@ namespace PRS.Shared
         public string PayID { get; set; } = "";
     }
 
+    public enum TimesheetTimberlineActivityCalculation
+    {
+        TimesheetOnly,
+        TimesheetPriority,
+        AssignmentPriority
+    }
+
     public class TimesheetTimberlineSettings : TimberlinePosterSettings<TimeSheet>
     {
-        [CheckBoxEditor(ToolTip = "Include in the export any assignments which exist outside of the relevant timesheet; if this is false, only assignments which are within the bounds of a timesheet are exported")]
-        public bool IncludeExtraneousAssignments { get; set; }
+        [EnumLookupEditor(typeof(TimesheetTimberlineActivityCalculation), LookupWidth = 200)]
+        public TimesheetTimberlineActivityCalculation ActivityCalculation { get; set; }
 
         protected override string DefaultScript()
         {
@@ -76,6 +83,7 @@ public class Module
 
         public event ITimberlinePoster<TimeSheet, TimesheetTimberlineSettings>.AddFragmentCallback? AddFragment;
 
+        private Dictionary<Guid, Activity> _activities = null!; // Initialised on DoProcess()
         private Dictionary<Guid, OvertimeInterval[]> _overtimeIntervals = null!; // Initialised on DoProcess()
 
         public bool BeforePost(IDataModel<TimeSheet> model)
@@ -146,19 +154,17 @@ public class Module
 
             public TimeSheet TimeSheet { get; set; }
 
-            public ActivityBlock(Assignment assignment, IList<TimeSheet> sheets)
+            public TimeSpan Duration => Finish - Start;
+
+            public ActivityBlock(Assignment assignment, TimeSheet sheet)
             {
-                Activity = assignment.ActivityLink.ID;
+                Activity = assignment.ActivityLink.ID != Guid.Empty
+                    ? assignment.ActivityLink.ID
+                    : sheet.ActivityLink.ID;
+
                 Start = assignment.EffectiveStartTime();
                 Finish = assignment.EffectiveFinishTime();
-
-                TimeSheet = sheets.FirstOrDefault(ContainedInTimeSheet)
-                    ?? sheets.MinBy(sheet =>
-                    {
-                        // Grab the closest timesheet
-                        return Math.Min((Start - sheet.ApprovedFinish).TotalHours, (sheet.ApprovedStart - Finish).TotalHours);
-                    })
-                    ?? sheets.First();
+                TimeSheet = sheet;
             }
 
             public ActivityBlock(TimeSheet sheet)
@@ -169,32 +175,114 @@ public class Module
                 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;
+            }
         }
 
-        private List<ActivityBlock> GetActivityBlocks(IEnumerable<Assignment> assignments, IList<TimeSheet> sheets)
+        private IEnumerable<ActivityBlock> GetMaskedActivityBlocks(IEnumerable<Assignment> assignments, TimeSheet sheet)
         {
-            var activityBlocks = new List<ActivityBlock>();
-            if (Settings.IncludeExtraneousAssignments)
+            if (sheet.ActivityLink.ID != Guid.Empty
+                && _activities.TryGetValue(sheet.ActivityLink.ID, out var activity)
+                && activity.IsLeave)
             {
-                activityBlocks.AddRange(assignments.Select(x => new ActivityBlock(x, sheets)));
+                yield return new ActivityBlock(sheet);
+                yield break;
             }
-            else
+
+            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)
             {
-                activityBlocks.AddRange(assignments.Select(x => new ActivityBlock(x, sheets)).Where(block =>
+                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)
                 {
-                    return sheets.Any(sheet => block.ContainedInTimeSheet(sheet));
-                }));
+                    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;
+                }
             }
-            if (activityBlocks.Count == 0)
+
+            var curTime = sheet.ApprovedStart;
+            foreach(var block in blocks)
             {
-                foreach (var sheet in sheets)
+                if (block.Start > curTime)
                 {
-                    activityBlocks.Add(new ActivityBlock(sheet));
+                    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<ActivityBlock> GetActivityBlocks(IEnumerable<Assignment> assignments, IList<TimeSheet> 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}");
             }
-            return activityBlocks.OrderBy(x => x.Start).ToList();
         }
 
         private interface IBlock
@@ -325,7 +413,7 @@ public class Module
                 throw new Exception("No approved timesheets found");
             }
 
-            var activities = model.GetTable<Activity>().ToObjects<Activity>().ToDictionary(x => x.ID, x => x);
+            _activities = model.GetTable<Activity>().ToObjects<Activity>().ToDictionary(x => x.ID, x => x);
             _overtimeIntervals = model.GetTable<OvertimeInterval>().ToObjects<OvertimeInterval>()
                 .GroupBy(x => x.Overtime.ID)
                 .ToDictionary(x => x.Key, x => x.OrderBy(x => x.Sequence).ToArray());
@@ -348,17 +436,18 @@ public class Module
 
                 var leave = new List<LeaveBlock>();
                 var workTime = new List<PaidWorkBlock>();
-                var totalDuration = new TimeSpan();
                 foreach (var block in activityBlocks)
                 {
                     string payID;
                     bool isLeave;
-                    Guid activityID = block.Activity != Guid.Empty ? block.Activity : block.TimeSheet.ActivityLink.ID;
 
-                    if (activityID == Guid.Empty
-                        || !activities.TryGetValue(block.Activity, out var activity))
+                    if (block.Activity == Guid.Empty
+                        || !_activities.TryGetValue(block.Activity, out var activity))
                     {
-                        Logger.Send(LogType.Error, "", $"Error in Timesheet Timberline export: Activity {activityID} does not exist!");
+                        if(block.Activity != Guid.Empty)
+                        {
+                            Logger.Send(LogType.Error, "", $"Error in Timesheet Timberline export: Activity {block.Activity} does not exist!");
+                        }
 
                         payID = "";
                         isLeave = false;
@@ -369,7 +458,6 @@ public class Module
                         payID = activity.PayrollID;
                     }
 
-                    totalDuration = totalDuration.Add(block.Finish - block.Start);
                     if (isLeave)
                     {
                         leave.Add(new(payID, block.Finish - block.Start));
@@ -381,19 +469,12 @@ public class Module
                     }
                 }
 
-                if (totalDuration > TimeSpan.Zero && approvedDuration > TimeSpan.Zero)
+                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 scaleFactor = approvedDuration.TotalHours / totalDuration.TotalHours;
-
-                    foreach(var block in (workTime as IEnumerable<IBlock>).Concat(leave))
-                    {
-                        block.Duration = TimeSpan.FromHours(block.Duration.TotalHours * scaleFactor);
-                    }
-
                     var workItems = EvaluateOvertime(workTime, overtimeID);
 
                     var blocks = (workItems as IEnumerable<IBlock>).Concat(leave);