From 8fd72be3d2a792361259d0038408d0c84e106867 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 9 Feb 2026 18:57:46 +0100 Subject: [PATCH] feature: GET /lookups/ranges endpoint returning lookup range brackets with computed upper bounds Added LookupRangeBracket and LookupRangeResult POCOs, BuildRangeBrackets extension method on LookupSet, API DTOs, Mapperly mappers, and payroll controller endpoint at {payrollId}/lookups/ranges supporting threshold and progressive range modes with optional range value matching. Updated swagger.json with new path and component schemas. Co-Authored-By: Claude Opus 4.6 --- Api/Api.Controller/PayrollController.cs | 57 +++++++ Api/Api.Map/LookupRangeBracketMap.cs | 15 ++ Api/Api.Map/LookupRangeResultMap.cs | 15 ++ Api/Api.Model/LookupRangeBracket.cs | 38 +++++ Api/Api.Model/LookupRangeResult.cs | 45 +++++ Api/Api.Model/PayrollEngine.Api.Model.xml | 71 ++++++++ Backend.Controller/PayrollController.cs | 36 ++++ Domain/Domain.Model/LookupRangeBracket.cs | 70 ++++++++ Domain/Domain.Model/LookupRangeResult.cs | 77 +++++++++ Domain/Domain.Model/LookupSetExtensions.cs | 76 +++++++++ .../PayrollEngine.Domain.Model.xml | 109 ++++++++++++ docs/swagger.json | 158 ++++++++++++++++++ 12 files changed, 767 insertions(+) create mode 100644 Api/Api.Map/LookupRangeBracketMap.cs create mode 100644 Api/Api.Map/LookupRangeResultMap.cs create mode 100644 Api/Api.Model/LookupRangeBracket.cs create mode 100644 Api/Api.Model/LookupRangeResult.cs create mode 100644 Domain/Domain.Model/LookupRangeBracket.cs create mode 100644 Domain/Domain.Model/LookupRangeResult.cs diff --git a/Api/Api.Controller/PayrollController.cs b/Api/Api.Controller/PayrollController.cs index 58a6d5f..82c0c3d 100644 --- a/Api/Api.Controller/PayrollController.cs +++ b/Api/Api.Controller/PayrollController.cs @@ -1455,6 +1455,63 @@ await caseValueTool.GetTimeCaseValuesAsync(caseValueDate, caseType, caseFieldNam return new LookupValueDataMap().ToApi(valueData); } + protected async Task> GetPayrollLookupRangesAsync( + DomainObject.PayrollQuery query, string[] lookupNames, decimal? rangeValue, string culture) + { + try + { + // tenant + var authResult = await TenantRequestAsync(query.TenantId); + if (authResult != null) + { + return authResult; + } + + // validate lookup names + if (lookupNames == null || lookupNames.Length == 0) + { + return BadRequest("Missing lookup names"); + } + + // query setup + var setupQuery = await SetupQuery(query); + if (setupQuery.Item2 != null) + { + // invalid setup response + return setupQuery.Item2; + } + var querySetup = setupQuery.Item1; + + query.RegulationDate ??= CurrentEvaluationDate; + query.EvaluationDate ??= CurrentEvaluationDate; + + // lookup provider + var lookupProvider = this.NewRegulationLookupProvider(Runtime.DbContext, querySetup.Tenant, + querySetup.Payroll, query.RegulationDate, query.EvaluationDate); + + // build range brackets for each lookup + var results = new List(); + foreach (var lookupName in lookupNames) + { + var lookupSet = await lookupProvider.GetLookupAsync(Runtime.DbContext, lookupName); + if (lookupSet == null) + { + return BadRequest($"Unknown lookup {lookupName}"); + } + + var result = DomainObject.LookupSetExtensions.BuildRangeBrackets(lookupSet, rangeValue); + result.LookupName = lookupName; + results.Add(result); + } + + return new LookupRangeResultMap().ToApi(results); + } + catch (Exception exception) + { + return InternalServerError(exception); + } + } + protected async Task> GetPayrollReportsAsync( DomainObject.PayrollQuery query, string[] reportNames, OverrideType? overrideType, UserType? userType, string clusterSetName) diff --git a/Api/Api.Map/LookupRangeBracketMap.cs b/Api/Api.Map/LookupRangeBracketMap.cs new file mode 100644 index 0000000..ad76f9f --- /dev/null +++ b/Api/Api.Map/LookupRangeBracketMap.cs @@ -0,0 +1,15 @@ +using DomainObject = PayrollEngine.Domain.Model; +using ApiObject = PayrollEngine.Api.Model; +using Riok.Mapperly.Abstractions; + +namespace PayrollEngine.Api.Map; + +/// +/// Map a domain object with an api object +/// +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName, EnumMappingIgnoreCase = true)] +public partial class LookupRangeBracketMap : ApiMapBase +{ + public override partial ApiObject.LookupRangeBracket ToApi(DomainObject.LookupRangeBracket domainObject); + public override partial DomainObject.LookupRangeBracket ToDomain(ApiObject.LookupRangeBracket apiObject); +} diff --git a/Api/Api.Map/LookupRangeResultMap.cs b/Api/Api.Map/LookupRangeResultMap.cs new file mode 100644 index 0000000..574a0ed --- /dev/null +++ b/Api/Api.Map/LookupRangeResultMap.cs @@ -0,0 +1,15 @@ +using DomainObject = PayrollEngine.Domain.Model; +using ApiObject = PayrollEngine.Api.Model; +using Riok.Mapperly.Abstractions; + +namespace PayrollEngine.Api.Map; + +/// +/// Map a domain object with an api object +/// +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName, EnumMappingIgnoreCase = true)] +public partial class LookupRangeResultMap : ApiMapBase +{ + public override partial ApiObject.LookupRangeResult ToApi(DomainObject.LookupRangeResult domainObject); + public override partial DomainObject.LookupRangeResult ToDomain(ApiObject.LookupRangeResult apiObject); +} diff --git a/Api/Api.Model/LookupRangeBracket.cs b/Api/Api.Model/LookupRangeBracket.cs new file mode 100644 index 0000000..e01e72c --- /dev/null +++ b/Api/Api.Model/LookupRangeBracket.cs @@ -0,0 +1,38 @@ +namespace PayrollEngine.Api.Model; + +/// +/// A lookup range bracket with computed bounds +/// +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable PropertyCanBeMadeInitOnly.Global +public class LookupRangeBracket +{ + /// + /// The lookup value key + /// + public string Key { get; set; } + + /// + /// The lookup value as JSON + /// + public string Value { get; set; } + + /// + /// The range start (lower bound) + /// + public decimal LowerBound { get; set; } + + /// + /// The range end (upper bound), null for unbounded last bracket + /// + public decimal? UpperBound { get; set; } + + /// + /// The original range value from the lookup value + /// + public decimal? RangeValue { get; set; } + + /// + public override string ToString() => + $"{Key}: {LowerBound} - {UpperBound}"; +} diff --git a/Api/Api.Model/LookupRangeResult.cs b/Api/Api.Model/LookupRangeResult.cs new file mode 100644 index 0000000..1d72eb4 --- /dev/null +++ b/Api/Api.Model/LookupRangeResult.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace PayrollEngine.Api.Model; + +/// +/// Result of a lookup range bracket computation +/// +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable PropertyCanBeMadeInitOnly.Global +public class LookupRangeResult +{ + /// + /// The lookup name + /// + public string LookupName { get; set; } + + /// + /// The lookup range mode + /// + public LookupRangeMode RangeMode { get; set; } + + /// + /// The lookup range size + /// + public decimal? RangeSize { get; set; } + + /// + /// The matching bracket for threshold mode, null for progressive + /// + public LookupRangeBracket MatchingBracket { get; set; } + + /// + /// All matching brackets for progressive mode, null for threshold + /// + public List MatchingBrackets { get; set; } + + /// + /// All range brackets + /// + public List AllBrackets { get; set; } + + /// + public override string ToString() => + LookupName; +} diff --git a/Api/Api.Model/PayrollEngine.Api.Model.xml b/Api/Api.Model/PayrollEngine.Api.Model.xml index f17ab3a..90002a0 100644 --- a/Api/Api.Model/PayrollEngine.Api.Model.xml +++ b/Api/Api.Model/PayrollEngine.Api.Model.xml @@ -2154,6 +2154,77 @@ + + + A lookup range bracket with computed bounds + + + + + The lookup value key + + + + + The lookup value as JSON + + + + + The range start (lower bound) + + + + + The range end (upper bound), null for unbounded last bracket + + + + + The original range value from the lookup value + + + + + + + + Result of a lookup range bracket computation + + + + + The lookup name + + + + + The lookup range mode + + + + + The lookup range size + + + + + The matching bracket for threshold mode, null for progressive + + + + + All matching brackets for progressive mode, null for threshold + + + + + All range brackets + + + + + Lookup including the lookup value diff --git a/Backend.Controller/PayrollController.cs b/Backend.Controller/PayrollController.cs index 2f7946d..ee5ff23 100644 --- a/Backend.Controller/PayrollController.cs +++ b/Backend.Controller/PayrollController.cs @@ -739,6 +739,42 @@ public override async Task QueryPayrollCaseChangeValuesAsync(int t lookupName, lookupKey, rangeValue, culture); } + /// + /// Get payroll lookup range brackets + /// + /// The tenant id + /// The payroll id + /// The lookup names (case-insensitive) + /// Optional value to find matching bracket(s) + /// The regulation date (default: UTC now) + /// The evaluation date (default: UTC now) + /// The content culture + /// The lookup range results + [HttpGet("{payrollId}/lookups/ranges")] + [OkResponse] + [NotFoundResponse] + [ApiOperationId("GetPayrollLookupRanges")] + public async Task> GetPayrollLookupRangesAsync(int tenantId, int payrollId, + [FromQuery][Required] string[] lookupNames, [FromQuery] decimal? rangeValue, + [FromQuery] DateTime? regulationDate, [FromQuery] DateTime? evaluationDate, [FromQuery] string culture) + { + // authorization + var authResult = await TenantRequestAsync(tenantId); + if (authResult != null) + { + return authResult; + } + return await base.GetPayrollLookupRangesAsync( + new() + { + TenantId = tenantId, + PayrollId = payrollId, + RegulationDate = regulationDate, + EvaluationDate = evaluationDate + }, + lookupNames, rangeValue, culture); + } + /// /// Get payroll report sets /// diff --git a/Domain/Domain.Model/LookupRangeBracket.cs b/Domain/Domain.Model/LookupRangeBracket.cs new file mode 100644 index 0000000..2b94084 --- /dev/null +++ b/Domain/Domain.Model/LookupRangeBracket.cs @@ -0,0 +1,70 @@ +using System; + +namespace PayrollEngine.Domain.Model; + +/// +/// A lookup range bracket with computed bounds +/// +// ReSharper disable PropertyCanBeMadeInitOnly.Global +public class LookupRangeBracket : IEquatable +{ + /// + /// The lookup value key + /// + public string Key { get; set; } + + /// + /// The lookup value as JSON + /// + public string Value { get; set; } + + /// + /// The range start (lower bound) + /// + public decimal LowerBound { get; set; } + + /// + /// The range end (upper bound), null for unbounded last bracket + /// + public decimal? UpperBound { get; set; } + + /// + /// The original range value from the lookup value + /// + public decimal? RangeValue { get; set; } + + /// + /// Default constructor + /// + public LookupRangeBracket() + { + } + + /// + /// Copy constructor + /// + /// The source to copy + public LookupRangeBracket(LookupRangeBracket source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + Key = source.Key; + Value = source.Value; + LowerBound = source.LowerBound; + UpperBound = source.UpperBound; + RangeValue = source.RangeValue; + } + + /// Compare two objects + /// The object to compare with this + /// True for objects with the same data + public bool Equals(LookupRangeBracket compare) => + CompareTool.EqualProperties(this, compare); + + /// + public override string ToString() => + $"{Key}: {LowerBound} - {UpperBound}"; +} diff --git a/Domain/Domain.Model/LookupRangeResult.cs b/Domain/Domain.Model/LookupRangeResult.cs new file mode 100644 index 0000000..03e0ae1 --- /dev/null +++ b/Domain/Domain.Model/LookupRangeResult.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +namespace PayrollEngine.Domain.Model; + +/// +/// Result of a lookup range bracket computation +/// +// ReSharper disable PropertyCanBeMadeInitOnly.Global +public class LookupRangeResult : IEquatable +{ + /// + /// The lookup name + /// + public string LookupName { get; set; } + + /// + /// The lookup range mode + /// + public LookupRangeMode RangeMode { get; set; } + + /// + /// The lookup range size + /// + public decimal? RangeSize { get; set; } + + /// + /// The matching bracket for threshold mode, null for progressive + /// + public LookupRangeBracket MatchingBracket { get; set; } + + /// + /// All matching brackets for progressive mode, null for threshold + /// + public List MatchingBrackets { get; set; } + + /// + /// All range brackets + /// + public List AllBrackets { get; set; } + + /// + /// Default constructor + /// + public LookupRangeResult() + { + } + + /// + /// Copy constructor + /// + /// The source to copy + public LookupRangeResult(LookupRangeResult source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + LookupName = source.LookupName; + RangeMode = source.RangeMode; + RangeSize = source.RangeSize; + MatchingBracket = source.MatchingBracket; + MatchingBrackets = source.MatchingBrackets; + AllBrackets = source.AllBrackets; + } + + /// Compare two objects + /// The object to compare with this + /// True for objects with the same data + public bool Equals(LookupRangeResult compare) => + CompareTool.EqualProperties(this, compare); + + /// + public override string ToString() => + LookupName; +} diff --git a/Domain/Domain.Model/LookupSetExtensions.cs b/Domain/Domain.Model/LookupSetExtensions.cs index 9a36c38..59fccdb 100644 --- a/Domain/Domain.Model/LookupSetExtensions.cs +++ b/Domain/Domain.Model/LookupSetExtensions.cs @@ -71,6 +71,82 @@ private static decimal ApplyProgressiveRangeValue(this LookupSet lookup, decimal #endregion + #region Range Brackets + + /// Build range brackets with computed upper bounds + /// The lookup set + /// Optional value to find matching bracket(s) + /// Lookup range result with all brackets and optional match + public static LookupRangeResult BuildRangeBrackets(this LookupSet lookup, decimal? rangeValue = null) + { + var brackets = new List(); + + if (lookup.Values != null) + { + for (var i = 0; i < lookup.Values.Count; i++) + { + var lookupValue = lookup.Values[i]; + + // ignore lookup values without range and lookup value + if (lookupValue.RangeValue == null || string.IsNullOrWhiteSpace(lookupValue.Value)) + { + continue; + } + + // update previous upper bound + if (brackets.Count > 0) + { + brackets[^1].UpperBound = lookupValue.RangeValue.Value; + } + + // add new bracket + brackets.Add(new LookupRangeBracket + { + Key = lookupValue.Key, + Value = lookupValue.Value, + LowerBound = lookupValue.RangeValue.Value, + RangeValue = lookupValue.RangeValue + }); + } + + // last bracket: upper bound from range size + if (brackets.Count > 0 && lookup.RangeSize.HasValue) + { + var last = brackets[^1]; + last.UpperBound = last.LowerBound + lookup.RangeSize.Value; + } + } + + var result = new LookupRangeResult + { + RangeMode = lookup.RangeMode, + RangeSize = lookup.RangeSize, + AllBrackets = brackets + }; + + // find matching bracket(s) + if (rangeValue.HasValue) + { + switch (lookup.RangeMode) + { + case LookupRangeMode.Threshold: + result.MatchingBracket = brackets.FirstOrDefault(b => + rangeValue.Value >= b.LowerBound && + (!b.UpperBound.HasValue || rangeValue.Value < b.UpperBound.Value)); + break; + case LookupRangeMode.Progressive: + result.MatchingBrackets = brackets + .Where(b => b.LowerBound < rangeValue.Value) + .ToList(); + break; + } + } + + return result; + } + + #endregion + #region Lookup Range private sealed class LookupRange diff --git a/Domain/Domain.Model/PayrollEngine.Domain.Model.xml b/Domain/Domain.Model/PayrollEngine.Domain.Model.xml index 3fc9b15..e3bc065 100644 --- a/Domain/Domain.Model/PayrollEngine.Domain.Model.xml +++ b/Domain/Domain.Model/PayrollEngine.Domain.Model.xml @@ -4448,6 +4448,109 @@ + + + A lookup range bracket with computed bounds + + + + + The lookup value key + + + + + The lookup value as JSON + + + + + The range start (lower bound) + + + + + The range end (upper bound), null for unbounded last bracket + + + + + The original range value from the lookup value + + + + + Default constructor + + + + + Copy constructor + + The source to copy + + + Compare two objects + The object to compare with this + True for objects with the same data + + + + + + + Result of a lookup range bracket computation + + + + + The lookup name + + + + + The lookup range mode + + + + + The lookup range size + + + + + The matching bracket for threshold mode, null for progressive + + + + + All matching brackets for progressive mode, null for threshold + + + + + All range brackets + + + + + Default constructor + + + + + Copy constructor + + The source to copy + + + Compare two objects + The object to compare with this + True for objects with the same data + + + + Lookup including the lookup value @@ -4499,6 +4602,12 @@ The first lookup range value must be zero. Summary of all lookup ranges + + Build range brackets with computed upper bounds + The lookup set + Optional value to find matching bracket(s) + Lookup range result with all brackets and optional match + The lookup settings diff --git a/docs/swagger.json b/docs/swagger.json index 4354a57..6a8c1dd 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -14116,6 +14116,101 @@ } } }, + "/api/tenants/{tenantId}/payrolls/{payrollId}/lookups/ranges": { + "get": { + "tags": [ + "Payrolls" + ], + "operationId": "GetPayrollLookupRanges", + "parameters": [ + { + "name": "tenantId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "payrollId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "lookupNames", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "rangeValue", + "in": "query", + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "regulationDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "evaluationDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LookupRangeResult" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/tenants/{tenantId}/payrolls/{payrollId}/reports": { "get": { "tags": [ @@ -28718,6 +28813,69 @@ }, "additionalProperties": false }, + "LookupRangeBracket": { + "type": "object", + "properties": { + "key": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + }, + "lowerBound": { + "type": "number", + "format": "double" + }, + "upperBound": { + "type": "number", + "format": "double", + "nullable": true + }, + "rangeValue": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "additionalProperties": false + }, + "LookupRangeResult": { + "type": "object", + "properties": { + "lookupName": { + "type": "string", + "nullable": true + }, + "rangeMode": { + "$ref": "#/components/schemas/LookupRangeMode" + }, + "rangeSize": { + "type": "number", + "format": "double", + "nullable": true + }, + "matchingBracket": { + "$ref": "#/components/schemas/LookupRangeBracket" + }, + "matchingBrackets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LookupRangeBracket" + }, + "nullable": true + }, + "allBrackets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LookupRangeBracket" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "MemberInfo": { "type": "object", "properties": {