diff --git a/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs b/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs index b744d5ad57..fe7e3e3f4c 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs @@ -366,6 +366,10 @@ internal static class TexlStrings public static StringGetter AboutEOMonth = (b) => StringResources.Get("AboutEOMonth", b); public static StringGetter EOMonthArg1 = (b) => StringResources.Get("EOMonthArg1", b); public static StringGetter EOMonthArg2 = (b) => StringResources.Get("EOMonthArg2", b); + public static StringGetter AboutWorkday = (b) => StringResources.Get("AboutWorkday", b); + public static StringGetter WorkdayArg1 = (b) => StringResources.Get("WorkdayArg1", b); + public static StringGetter WorkdayArg2 = (b) => StringResources.Get("WorkdayArg2", b); + public static StringGetter WorkdayArg3 = (b) => StringResources.Get("WorkdayArg3", b); public static StringGetter AboutCalendar__MonthsLong = (b) => StringResources.Get("AboutCalendar__MonthsLong", b); public static StringGetter AboutCalendar__MonthsShort = (b) => StringResources.Get("AboutCalendar__MonthsShort", b); diff --git a/src/libraries/Microsoft.PowerFx.Core/Texl/BuiltinFunctionsCore.cs b/src/libraries/Microsoft.PowerFx.Core/Texl/BuiltinFunctionsCore.cs index 5822db9f2f..4b4c541bd7 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Texl/BuiltinFunctionsCore.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Texl/BuiltinFunctionsCore.cs @@ -104,8 +104,9 @@ internal class BuiltinFunctionsCore public static readonly TexlFunction Degrees = _library.Add(new DegreesFunction()); public static readonly TexlFunction DegreesT = _library.Add(new DegreesTableFunction()); public static readonly TexlFunction DropColumns = _library.Add(new DropColumnsFunction()); - public static readonly TexlFunction EDate = _library.Add(new EDateFunction()); - public static readonly TexlFunction EOMonth = _library.Add(new EOMonthFunction()); + public static readonly TexlFunction EDate = _library.Add(new EDateFunction()); + public static readonly TexlFunction EOMonth = _library.Add(new EOMonthFunction()); + public static readonly TexlFunction Workday = _library.Add(new WorkdayFunction()); public static readonly TexlFunction EncodeHTML = _library.Add(new EncodeHTMLFunction()); public static readonly TexlFunction EncodeUrl = _library.Add(new EncodeUrlFunction()); public static readonly TexlFunction EndsWith = _library.Add(new EndsWithFunction()); diff --git a/src/libraries/Microsoft.PowerFx.Core/Texl/Builtins/DateTime.cs b/src/libraries/Microsoft.PowerFx.Core/Texl/Builtins/DateTime.cs index a5c17a4068..069a9e239d 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Texl/Builtins/DateTime.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Texl/Builtins/DateTime.cs @@ -371,7 +371,37 @@ public EOMonthFunction() public override IEnumerable GetSignatures() { - yield return new[] { TexlStrings.EOMonthArg1, TexlStrings.EOMonthArg2 }; + yield return new[] { TexlStrings.EOMonthArg1, TexlStrings.EOMonthArg2 }; + } + } + + // Workday() + // Equivalent Excel function: WORKDAY + internal sealed class WorkdayFunction : BuiltinFunction + { + public override bool IsSelfContained => true; + + public WorkdayFunction() + : base("Workday", TexlStrings.AboutWorkday, FunctionCategories.DateTime, DType.Date, 0, 2, int.MaxValue, DType.DateTime, DType.Number) + { + } + + public override IEnumerable GetSignatures() + { + yield return new[] { TexlStrings.WorkdayArg1, TexlStrings.WorkdayArg2 }; + yield return new[] { TexlStrings.WorkdayArg1, TexlStrings.WorkdayArg2, TexlStrings.WorkdayArg3 }; + yield return new[] { TexlStrings.WorkdayArg1, TexlStrings.WorkdayArg2, TexlStrings.WorkdayArg3, TexlStrings.WorkdayArg3 }; + yield return new[] { TexlStrings.WorkdayArg1, TexlStrings.WorkdayArg2, TexlStrings.WorkdayArg3, TexlStrings.WorkdayArg3, TexlStrings.WorkdayArg3 }; + } + + public override IEnumerable GetSignatures(int arity) + { + if (arity > 2) + { + return GetGenericSignatures(arity, TexlStrings.WorkdayArg1, TexlStrings.WorkdayArg2, TexlStrings.WorkdayArg3); + } + + return base.GetSignatures(arity); } } diff --git a/src/libraries/Microsoft.PowerFx.Interpreter/Functions/Library.cs b/src/libraries/Microsoft.PowerFx.Interpreter/Functions/Library.cs index ab580f2dd0..e862f149e2 100644 --- a/src/libraries/Microsoft.PowerFx.Interpreter/Functions/Library.cs +++ b/src/libraries/Microsoft.PowerFx.Interpreter/Functions/Library.cs @@ -653,6 +653,19 @@ static Library() returnBehavior: ReturnBehavior.AlwaysEvaluateAndReturnResult, targetFunction: EOMonth) }, + { + BuiltinFunctionsCore.Workday, + StandardErrorHandling( + BuiltinFunctionsCore.Workday.Name, + expandArguments: NoArgExpansion, + replaceBlankValues: ReplaceBlankWith( + new DateTimeValue(IRContext.NotInSource(FormulaType.DateTime), _epoch), + new NumberValue(IRContext.NotInSource(FormulaType.Number), 0)), + checkRuntimeTypes: DeferRuntimeTypeChecking, + checkRuntimeValues: DeferRuntimeValueChecking, + returnBehavior: ReturnBehavior.AlwaysEvaluateAndReturnResult, + targetFunction: Workday) + }, { BuiltinFunctionsCore.EncodeHTML, StandardErrorHandling( diff --git a/src/libraries/Microsoft.PowerFx.Interpreter/Functions/LibraryDate.cs b/src/libraries/Microsoft.PowerFx.Interpreter/Functions/LibraryDate.cs index 93b1c842d4..1384680efd 100644 --- a/src/libraries/Microsoft.PowerFx.Interpreter/Functions/LibraryDate.cs +++ b/src/libraries/Microsoft.PowerFx.Interpreter/Functions/LibraryDate.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Globalization; using System.Linq; @@ -926,6 +927,111 @@ public static FormulaValue EOMonth(EvalVisitor runner, EvalVisitorContext contex } } + public static FormulaValue Workday(EvalVisitor runner, EvalVisitorContext context, IRContext irContext, FormulaValue[] args) + { + // Check for ErrorValue in any argument and return it immediately + foreach (var arg in args) + { + if (arg is ErrorValue) + { + return arg; + } + } + + var timeZoneInfo = runner.TimeZoneInfo; + + DateTime startDate = runner.GetNormalizedDateTime(args[0]); + + if (args[1] is not NumberValue daysValue) + { + throw CommonExceptions.RuntimeMisMatch; + } + + // Truncate toward zero for Excel compatibility + int days = (int)Math.Truncate(daysValue.Value); + + // Collect holidays from individual date arguments (args[2] onwards) + var holidays = new HashSet(); + for (int i = 2; i < args.Length; i++) + { + if (args[i] is not BlankValue) + { + if (args[i] is DateValue dateValue) + { + holidays.Add(dateValue.GetConvertedValue(timeZoneInfo).Date); + } + else if (args[i] is DateTimeValue dateTimeValue) + { + holidays.Add(dateTimeValue.GetConvertedValue(timeZoneInfo).Date); + } + else if (args[i] is TableValue holidayTable) + { + // Also support table for backward compatibility + foreach (var row in holidayTable.Rows) + { + if (row.IsValue) + { + var fields = row.Value.Fields.ToArray(); + if (fields.Length > 0) + { + var field = fields[0]; + if (field.Value is DateValue dateVal) + { + holidays.Add(dateVal.GetConvertedValue(timeZoneInfo).Date); + } + else if (field.Value is DateTimeValue dateTimeVal) + { + holidays.Add(dateTimeVal.GetConvertedValue(timeZoneInfo).Date); + } + } + } + } + } + } + } + + try + { + DateTime currentDate = startDate.Date; + + // Early return for zero days + if (days == 0) + { + DateTime resultDate = MakeValidDateTime(runner, currentDate, timeZoneInfo); + return new DateValue(irContext, resultDate); + } + + int direction = days > 0 ? 1 : -1; + int remainingDays = Math.Abs(days); + + while (remainingDays > 0) + { + currentDate = currentDate.AddDays(direction); + + // Check if it's a weekend (Saturday or Sunday) + if (currentDate.DayOfWeek == DayOfWeek.Saturday || currentDate.DayOfWeek == DayOfWeek.Sunday) + { + continue; + } + + // Check if it's a holiday + if (holidays.Contains(currentDate)) + { + continue; + } + + remainingDays--; + } + + DateTime newDate = MakeValidDateTime(runner, currentDate, timeZoneInfo); + return new DateValue(irContext, newDate); + } + catch + { + return CommonErrors.ArgumentOutOfRange(irContext); + } + } + private static double WeekStartDay(double startOfWeek) { if (startOfWeek == 1 || startOfWeek == 2) diff --git a/src/strings/PowerFxResources.en-US.resx b/src/strings/PowerFxResources.en-US.resx index d5e350d6d7..905cc2d563 100644 --- a/src/strings/PowerFxResources.en-US.resx +++ b/src/strings/PowerFxResources.en-US.resx @@ -1127,6 +1127,34 @@ Number of months by which to change the date. A positive value yields a future date, a negative value a past date, and zero will not change the month. Description of the second argument to the 'EOMonth' function. + + Returns a date that represents a specified number of working days before or after a starting date. Working days exclude weekends and any specified holidays. + Description of 'Workday' function. + + + start_date + function_parameter - First parameter for the Workday function - the starting date. + + + days + function_parameter - Second parameter for the Workday function - the number of working days to add or subtract. + + + holidays + function_parameter - Third and subsequent parameters for the Workday function - optional holiday dates to exclude. + + + A date value that represents the start date. + Description of the first argument to the 'Workday' function. + + + The number of nonweekend and nonholiday days before or after start_date. A positive value for days yields a future date; a negative value yields a past date. + Description of the second argument to the 'Workday' function. + + + Optional date values to exclude from the working calendar, such as state and federal holidays and floating holidays. Can also be a table of dates. + Description of the third and subsequent arguments to the 'Workday' function. + Truncates 'number' by rounding toward negative infinity. Description of 'Int' function. diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/Workday.txt b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/Workday.txt new file mode 100644 index 0000000000..5f6ad1c5a3 --- /dev/null +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/Workday.txt @@ -0,0 +1,124 @@ + +// WORKDAY basic tests + +// Basic forward workday calculations +>> Workday(Date(2024,8,24),30) +Date(2024,10,4) + +// Basic backward workday calculations +>> Workday(Date(2024,8,24),-10) +Date(2024,8,12) + +// Zero days should return the start date +>> Workday(Date(2024,8,24),0) +Date(2024,8,24) + +// Starting on a Saturday (weekend) +>> Workday(Date(2024,8,24),1) +Date(2024,8,26) + +// Starting on a Sunday (weekend) +>> Workday(Date(2024,8,25),1) +Date(2024,8,26) + +// Starting on a Friday +>> Workday(Date(2024,8,23),1) +Date(2024,8,26) + +// Starting on a Monday +>> Workday(Date(2024,8,26),1) +Date(2024,8,27) + +// Multiple weeks forward +>> Workday(Date(2024,1,15),20) +Date(2024,2,12) + +// Multiple weeks backward +>> Workday(Date(2024,2,15),-20) +Date(2024,1,18) + +// Crossing year boundary forward +>> Workday(Date(2024,12,20),10) +Date(2025,1,3) + +// Crossing year boundary backward +>> Workday(Date(2025,1,10),-10) +Date(2024,12,27) + +// Fractional days should be truncated +>> Workday(Date(2024,8,24),5.9) +Date(2024,8,30) + +// Negative fractional days should be truncated +>> Workday(Date(2024,8,24),-5.9) +Date(2024,8,19) + +// WORKDAY with holidays + +// Single holiday +>> Workday(Date(2024,8,24),5,Date(2024,8,28)) +Date(2024,9,2) + +// Single holiday as table (backward compatibility) +>> Workday(Date(2024,8,24),5,Table({Date:Date(2024,8,28)})) +Date(2024,9,2) + +// Multiple holidays +>> Workday(Date(2024,8,24),10,Date(2024,8,28),Date(2024,9,2)) +Date(2024,9,6) + +// Multiple holidays as table (backward compatibility) +>> Workday(Date(2024,8,24),10,Table({Date:Date(2024,8,28)},{Date:Date(2024,9,2)})) +Date(2024,9,6) + +// Holiday on weekend should not affect result +>> Workday(Date(2024,8,24),5,Date(2024,8,25)) +Date(2024,8,30) + +// Backward with holidays +>> Workday(Date(2024,8,30),-5,Date(2024,8,26)) +Date(2024,8,21) + +// Holiday that is the start date +>> Workday(Date(2024,8,26),5,Date(2024,8,26)) +Date(2024,9,2) + +// Multiple holidays in range +>> Workday(Date(2024,12,20),10,Date(2024,12,25),Date(2025,1,1)) +Date(2025,1,7) + +// WORKDAY with DateTime inputs + +>> Workday(DateTime(2024,8,24,12,30,45),5) +Date(2024,8,30) + +>> Workday(DateTime(2024,8,24,12,30,45),-5) +Date(2024,8,19) + +// WORKDAY with Blank values + +>> Workday(Blank(),10) +Date(1900,1,12) + +>> Workday(Date(2024,8,24),Blank()) +Date(2024,8,24) + +>> Workday(Blank(),Blank()) +Date(1899,12,30) + +// Edge cases + +// Large number of workdays forward +>> Workday(Date(2024,1,1),250) +Date(2024,12,16) + +// Large number of workdays backward +>> Workday(Date(2024,12,31),-250) +Date(2024,1,16) + +// Ensure date values are truncated and do not include time +>> Text(Workday(DateTime(2024,8,24,12,34,56),5),"yyyy-mm-dd hh:mm:ss") +"2024-08-30 00:00:00" + +>> Value(Workday(Date(2024,8,24),5)) +45535