| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858 | 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    {        /// <summary>        /// Represents a block of time having an <see cref="Comal.Classes.Activity"/>. It will always be linked to a timesheet.        /// </summary>        /// <remarks>        /// 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.        /// </remarks>        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;            /// <summary>            /// Create an <see cref="ActivityBlock"/> from an <see cref="Assignment"/>, taking the <see cref="Start"/> and <see cref="Finish"/> from            /// <see cref="Assignment.EffectiveStartTime"/> and <see cref="Assignment.EffectiveFinishTime"/>.            /// </summary>            /// <remarks>            /// The activity is sourced from the assignment, unless the assignment has no activity, in which case the activity on the timesheet is used.            /// </remarks>            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;            }            /// <summary>            /// Creates an <see cref="ActivityBlock"/> from the timesheet, using the <see            /// cref="TimeSheet.ApprovedStart"/> and <see cref="TimeSheet.ApprovedFinish"/>.            /// </summary>            public ActivityBlock(TimeSheet sheet)            {                Activity = sheet.ActivityLink.ID;                Start = sheet.ApprovedStart;                Finish = sheet.ApprovedFinish;                TimeSheet = sheet;            }            /// <summary>            /// Creates an <see cref="ActivityBlock"/> from the timesheet, but with a custom start and finish,            /// for use with masking activities.            /// </summary>            public ActivityBlock(TimeSheet sheet, TimeSpan start, TimeSpan finish)            {                Activity = sheet.ActivityLink.ID;                Start = start;                Finish = finish;                TimeSheet = sheet;            }            /// <summary>            /// Ensure that this <see cref="ActivityBlock"/> fits within the bounds of the <see cref="TimeSheet"/>.            /// </summary>            /// <returns>Itself.</returns>            public ActivityBlock Chop(TimeSheet sheet)            {                if (Start < sheet.ApprovedStart)                {                    Start = sheet.ApprovedStart;                }                if (Finish > sheet.ApprovedFinish)                {                    Finish = sheet.ApprovedFinish;                }                return this;            }            /// <summary>            /// Check if this block is partially or fully within the given timesheet.            /// </summary>            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<TimeSheet> model, Guid employee, DateTime date) : CancelEventArgs        {            public IDataModel<TimeSheet> Model { get; set; } = model;            public Guid Employee { get; set; } = employee;            public DateTime Date { get; set; } = date;        }        public class ProcessRawDataArgs(            IDataModel<TimeSheet> model, Guid employee, DateTime date,            List<TimeSheet> timeSheets, List<Assignment> assignments) : BaseArgs(model, employee, date)        {            public List<TimeSheet> TimeSheets { get; set; } = timeSheets;            public List<Assignment> Assignments { get; set; } = assignments;        }        public class ProcessActivityBlocksArgs(            IDataModel<TimeSheet> model, Guid employee, DateTime date,            List<ActivityBlock> activityBlocks) : BaseArgs(model, employee, date)        {            public List<ActivityBlock> ActivityBlocks { get; set; } = activityBlocks;        }        public class ProcessTimeBlocksArgs(            IDataModel<TimeSheet> model, Guid employee, DateTime date,            List<IBlock> blocks) : BaseArgs(model, employee, date)        {            public List<IBlock> Blocks { get; set; } = blocks;        }        public class ProcessItemArgs(            IDataModel<TimeSheet> 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<TimeSheet>    {        private List<TimesheetTimberlineItem> items = new List<TimesheetTimberlineItem>();        public IEnumerable<TimesheetTimberlineItem> 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    {        /// <summary>        /// Assignments are completely ignored by the export, so that the activities and time is solely provided by the time sheet.        /// </summary>        TimesheetOnly,        /// <summary>        /// Timesheets with activities are processed like in <see cref="TimesheetOnly"/>, but for timesheets without activities, the        /// assignments are used to generate time blocks, resorting to the timesheet where there is no assignment.        /// </summary>        TimesheetPriority,        /// <summary>        /// Leave timesheets are processed like <see cref="TimesheetOnly"/>, but all other timesheets use assignments to generate time blocks,        /// resorting to the timesheet when there is no assignment for a block of time.        /// </summary>        AssignmentPriority    }    public class TimesheetTimberlineSettings : TimberlinePosterSettings<TimeSheet>    {        [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<TimeSheet> 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<TimeSheet> model)    {        // Perform post-processing    }}";        }    }    public class TimesheetTimberlinePoster : ITimberlinePoster<TimeSheet, TimesheetTimberlineSettings>    {        public ScriptDocument? Script { get; set; }        public TimesheetTimberlineSettings Settings { get; set; }        private Dictionary<Guid, Activity> _activities = null!; // Initialised on DoProcess()        private Dictionary<Guid, OvertimeInterval[]> _overtimeIntervals = null!; // Initialised on DoProcess()        public bool BeforePost(IDataModel<TimeSheet> model)        {            model.RemoveTable<Document>("CompanyLogo");            model.RemoveTable<CoreTable>("CompanyInformation");            model.RemoveTable<Employee>();            model.RemoveTable<User>();            model.SetColumns<TimeSheet>(Columns.None<TimeSheet>().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<Activity>(                null,                Columns.None<Activity>().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<TimeSheet, Employee>(x => x.EmployeeLink.ID, x => x.ID,                new Filter<Employee>(x => x.PayrollID).IsNotEqualTo(""),                Columns.None<Employee>()                    .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<Employee, EmployeeRosterItem>(x => x.ID, x => x.Employee.ID,                columns: Columns.None<EmployeeRosterItem>()                    .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<EmployeeRosterItem, OvertimeInterval>(x => x.Overtime.ID, x => x.Overtime.ID,                null,                Columns.None<OvertimeInterval>().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<TimeSheet, Assignment>(x => x.Date, x => x.Date, null,                Columns.None<Assignment>().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 });        }        /// <summary>        /// Return a list of <see cref="ActivityBlock"/>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.        /// </summary>        /// <remarks>        /// <list type="bullet">        ///     <item>        ///         If the time sheet is of type leave, it completely overrides the assignments.        ///     </item>        ///     <item>        ///         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.        ///     </item>        /// </list>        /// </remarks>        /// <returns>        /// A list of activity blocks, starting at the beginning of the time sheet and filling        /// continuous time to the end of the time sheet.        /// </returns>        private IEnumerable<ActivityBlock> GetMaskedActivityBlocks(IEnumerable<Assignment> 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);            }        }        /// <summary>        /// Based on <see cref="TimesheetTimberlineSettings.ActivityCalculation"/>, split each        /// timesheet up into a number of activity blocks. Note that time is <b>only</b> 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.        /// </summary>        /// <remarks>        /// The output will have continuous activity blocks filling all the time of each time sheet.        /// <br/>        /// Note that if the timesheets themselves overlap, no special functionality exists, and        /// there will in this case be overlapping time blocks.        /// </remarks>        /// <param name="assignments">A list of assignments for this employee and date.</param>        /// <param name="sheets">A list of timesheets for this employee and date.</param>        /// <returns>A list of blocks of time that represent an activity the employee was doing.</returns>        private List<ActivityBlock> GetActivityBlocks(IEnumerable<Assignment> assignments, IList<TimeSheet> 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}");            }        }        /// <summary>        /// 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.        /// </summary>        private List<IBlock> EvaluateOvertime(IEnumerable<IBlock> 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<IBlock>();            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<TimeSheet> model)        {            var items = new TimeSheetTimberlineResult();            var timesheets = model.GetTable<TimeSheet>().ToObjects<TimeSheet>().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<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());            var rosters = model.GetTable<EmployeeRosterItem>("Rosters").ToObjects<EmployeeRosterItem>()                .GroupBy(x => x.Employee.ID).ToDictionary(x => x.Key, x => x.ToArray());            var employees = model.GetTable<Employee>("Employees").ToObjects<Employee>()                .ToDictionary(x => x.ID, x => x);            // We run through by date and employee, grouping both assignments and time sheets.            var assignments = model.GetTable<Assignment>().ToObjects<Assignment>()                .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<Assignment>());                // 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<IBlock>();                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<Tuple<TimesheetTimberlineItem, List<TimeSheet>>>();                // 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<TimeSheet> Process(IDataModel<TimeSheet> 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<TimeSheet> model, IPostResult<TimeSheet> result)        {            Script?.Execute(methodname: "AfterPost", parameters: new object[] { model });        }    }    public class TimesheetTimberlinePosterEngine<T> : TimberlinePosterEngine<TimeSheet, TimesheetTimberlineSettings>    {    }}
 |