From 4500e3329284df1304e50a750ccc421457f56b6b Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Mon, 16 Feb 2026 17:26:46 +0000 Subject: [PATCH] Add hourly sales by day API with comparison support Introduced a new GET endpoint `/todayssalesbyhour` that returns sales counts and values by hour for today and a comparison date. Added supporting query, handler, DTO, and model. Removed legacy hourly DTOs in favor of a unified structure. Updated reporting manager and DI registry. Added comprehensive integration tests to verify correct hourly aggregation and comparison. This enhances reporting capabilities for analyzing sales trends by hour. --- .../Queries/TransactionQueries.cs | 2 +- .../ReportingManager.cs | 64 +++++++-- .../TransactionRequestHandler.cs | 9 +- ...lesValueByHour.cs => TodaysSalesByHour.cs} | 6 +- .../TodaysSalesCountByHour.cs | 14 -- .../TransactionsEndpointTests.cs | 129 ++++++++++++++++++ .../TodaysSalesByHour.cs | 10 ++ .../Bootstrapper/MediatorRegistry.cs | 2 + .../Endpoints/TransactionEndpoints.cs | 2 + .../Handlers/TransactionHandler.cs | 23 ++++ .../archivecode/FactTransactionsHandler.cs | 2 - 11 files changed, 236 insertions(+), 27 deletions(-) rename EstateReportingAPI.DataTrasferObjects/{TodaysSalesValueByHour.cs => TodaysSalesByHour.cs} (61%) delete mode 100644 EstateReportingAPI.DataTrasferObjects/TodaysSalesCountByHour.cs create mode 100644 EstateReportingAPI.Models/TodaysSalesByHour.cs diff --git a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs index 17127e7..f2873b8 100644 --- a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs +++ b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs @@ -13,5 +13,5 @@ public record TransactionDetailReportQuery(Guid EstateId, TransactionDetailRepor public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummaryByMerchantRequest Request) : IRequest>; public record TransactionSummaryByOperatorQuery(Guid EstateId, TransactionSummaryByOperatorRequest Request) : IRequest>; public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate) : IRequest>; - + public record TodaysSalesByHour(Guid estateId, DateTime comparisonDate) : IRequest>>; } \ No newline at end of file diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index c985a09..d4c6445 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -52,6 +52,8 @@ Task> GetTransactionSummaryByOperat Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, CancellationToken cancellationToken); + Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, + CancellationToken cancellationToken); #endregion } @@ -1449,8 +1451,61 @@ into g return Result.Success(response); } + public async Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.estateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + IQueryable todaysSales = this.BuildTodaySalesQuery(context); + IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.comparisonDate); + + // First we need to get a value of todays sales + var todaysSalesByHour = await (from t in todaysSales + group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }).ToListAsync(cancellationToken); + + var comparisonSalesByHour = await (from t in comparisonSales group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }).ToListAsync(cancellationToken); + + var response = + ( + from today in todaysSalesByHour + join comparison in comparisonSalesByHour + on today.Hour equals comparison.Hour into compGroup + from comparison in compGroup.DefaultIfEmpty() + select new TodaysSalesByHour + { + Hour = today.Hour.Value, + TodaysSalesCount = today.TotalSalesCount, + TodaysSalesValue = today.TotalSalesValue, + ComparisonSalesCount = comparison?.TotalSalesCount ?? 0, + ComparisonSalesValue = comparison?.TotalSalesValue ?? 0 + } + ) + .Union + ( + from comparison in comparisonSalesByHour + join today in todaysSalesByHour + on comparison.Hour equals today.Hour into todayGroup + from today in todayGroup.DefaultIfEmpty() + where today == null + select new TodaysSalesByHour + { + Hour = comparison.Hour.Value, + TodaysSalesCount = 0, + TodaysSalesValue = 0, + ComparisonSalesCount = comparison.TotalSalesCount, + ComparisonSalesValue = comparison.TotalSalesValue + } + ) + .ToList(); + + + return Result.Success(response); + } + private IQueryable BuildTodaySalesQuery(EstateManagementContext context) { - return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == DateTime.Now.Date && t.TransactionTime <= DateTime.Now.TimeOfDay select t; + return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale" + && t.TransactionDate == DateTime.Now.Date && t.TransactionTime <= DateTime.Now.TimeOfDay + select t; } private IQueryable BuildComparisonSalesQuery(EstateManagementContext context, @@ -1458,13 +1513,6 @@ private IQueryable BuildComparisonSalesQuery(EstateManagemen return from t in context.TransactionHistory where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay select t; } - - - - - - - #endregion } diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs index 2041c05..04ae197 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs @@ -10,7 +10,9 @@ public class TransactionRequestHandler : IRequestHandler>, IRequestHandler>, IRequestHandler>, - IRequestHandler> + IRequestHandler>, + IRequestHandler>> + { private readonly IReportingManager Manager; @@ -49,4 +51,9 @@ public async Task> Handle(TransactionQueries. CancellationToken cancellationToken) { return await this.Manager.GetProductPerformanceReport(request, cancellationToken); } + + public async Task>> Handle(TransactionQueries.TodaysSalesByHour request, + CancellationToken cancellationToken) { + return await this.Manager.GetTodaysSalesByHour(request, cancellationToken); + } } \ No newline at end of file diff --git a/EstateReportingAPI.DataTrasferObjects/TodaysSalesValueByHour.cs b/EstateReportingAPI.DataTrasferObjects/TodaysSalesByHour.cs similarity index 61% rename from EstateReportingAPI.DataTrasferObjects/TodaysSalesValueByHour.cs rename to EstateReportingAPI.DataTrasferObjects/TodaysSalesByHour.cs index 77e7b90..0063e9e 100644 --- a/EstateReportingAPI.DataTrasferObjects/TodaysSalesValueByHour.cs +++ b/EstateReportingAPI.DataTrasferObjects/TodaysSalesByHour.cs @@ -2,12 +2,16 @@ using Newtonsoft.Json; using System; - public class TodaysSalesValueByHour{ + public class TodaysSalesByHour{ [JsonProperty("hour")] public Int32 Hour{ get; set; } [JsonProperty("todays_sales_value")] public Decimal TodaysSalesValue { get; set; } [JsonProperty("comparison_sales_value")] public Decimal ComparisonSalesValue { get; set; } + [JsonProperty("todays_sales_count")] + public Int32 TodaysSalesCount { get; set; } + [JsonProperty("comparison_sales_count")] + public Int32 ComparisonSalesCount { get; set; } } } \ No newline at end of file diff --git a/EstateReportingAPI.DataTrasferObjects/TodaysSalesCountByHour.cs b/EstateReportingAPI.DataTrasferObjects/TodaysSalesCountByHour.cs deleted file mode 100644 index 5545f2f..0000000 --- a/EstateReportingAPI.DataTrasferObjects/TodaysSalesCountByHour.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EstateReportingAPI.DataTransferObjects{ - using Newtonsoft.Json; - using System; - - public class TodaysSalesCountByHour - { - [JsonProperty("hour")] - public Int32 Hour { get; set; } - [JsonProperty("todays_sales_count")] - public Int32 TodaysSalesCount { get; set; } - [JsonProperty("comparison_sales_count")] - public Int32 ComparisonSalesCount { get; set; } - } -} \ No newline at end of file diff --git a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs index e47faea..ddb7fbb 100644 --- a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs @@ -939,6 +939,135 @@ public async Task TransactionsEndpoint_ProductPerformanceReport_SummaryDataRetur productPerformanceResponseDetail.TransactionCount.ShouldBe(transactions.Count(t => t.ContractProductId == product.productId)); productPerformanceResponseDetail.TransactionValue.ShouldBe(transactions.Where(t => t.ContractProductId == product.productId).Sum(t => t.TransactionAmount)); } + } + + private static int RandomizeCount(int baseCount, Random rnd, double variability = 0.3) + { + if (baseCount <= 0) return 0; + // factor in [-variability, +variability] + double factor = 1.0 + (rnd.NextDouble() * 2.0 - 1.0) * variability; + int result = (int)Math.Round(baseCount * factor); + return Math.Max(0, result); + } + + [Fact] + public async Task TransactionsEndpoint_TodaysSalesByHour_SummaryDataReturned() + { + Stopwatch sw = Stopwatch.StartNew(); + + List todaysTransactions = new List(); + List comparisonDateTransactions = new List(); + + Dictionary transactionCounts = new() { { "Test Merchant 1", 3 }, { "Test Merchant 2", 6 }, { "Test Merchant 3", 2 }, { "Test Merchant 4", 0 } }; + + // TODO: make counts dynamic + DateTime todaysDateTime = DateTime.Now; + + for (int hour = 0; hour < 24; hour++) + { + List localList = new List(); + DateTime date = new DateTime(todaysDateTime.Year, todaysDateTime.Month, todaysDateTime.Day, hour, 0, 0); + + // Seed per-hour RNG deterministically so test results are reproducible per-day/per-hour + var hourSeed = todaysDateTime.Date.GetHashCode() ^ hour; + var hourRnd = new Random(hourSeed); + foreach (var merchant in merchantsList) + { + foreach (var contract in contractList) + { + var productList = contractProducts.Single(cp => cp.Key == contract.contractId).Value; + foreach ((Guid productId, String productName, Decimal? productValue, Int32 contractProductReportingId) product in productList) + { + var baseCount = transactionCounts.Single(m => m.Key == merchant.Name).Value; + + // keep the original hour-based multipliers, but apply random variation to the final count + int hourMultiplierCount = hour switch + { + _ when hour >= 9 && hour < 18 => baseCount * hour, // business hours + _ when hour >= 18 && hour < 21 => baseCount * (24 - hour), // evening spike + _ => baseCount // off hours + }; + + int transactionCount = RandomizeCount(hourMultiplierCount, hourRnd, variability: 0.3); + + for (int i = 0; i < transactionCount; i++) + { + Transaction transaction = await helper.BuildTransactionX(date, merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue); + todaysTransactions.Add(transaction); + } + } + } + } + + todaysTransactions.AddRange(localList); + } + + await this.helper.AddTransactionsX(todaysTransactions); + + sw.Stop(); + this.TestOutputHelper.WriteLine($"Setup Todays Txns {sw.ElapsedMilliseconds}ms"); + sw.Restart(); + + DateTime comparisonDate = todaysDateTime.AddDays(-1); + for (int hour = 0; hour < 24; hour++) + { + List localList = new List(); + DateTime date = new DateTime(comparisonDate.Year, comparisonDate.Month, comparisonDate.Day, hour, 0, 0); + + // Separate deterministic seed for comparison date hour + var compHourSeed = comparisonDate.Date.GetHashCode() ^ hour; + var compHourRnd = new Random(compHourSeed); + + foreach (var merchant in merchantsList) + { + foreach (var contract in contractList) + { + var productList = contractProducts.Single(cp => cp.Key == contract.contractId).Value; + foreach (var product in productList) + { + var baseCount = transactionCounts.Single(m => m.Key == merchant.Name).Value; + + int hourMultiplierCount = hour switch + { + _ when hour >= 12 && hour < 18 => baseCount * hour, // business hours + _ when hour >= 18 && hour < 21 => baseCount * (24 - hour), // evening spike + _ => baseCount // off hours + }; + + int transactionCount = RandomizeCount(hourMultiplierCount, compHourRnd, variability: 0.3); + + for (int i = 0; i < transactionCount; i++) + { + Transaction transaction = await helper.BuildTransactionX(date, merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue); + comparisonDateTransactions.Add(transaction); + } + } + } + } + + comparisonDateTransactions.AddRange(localList); + + } + + await this.helper.AddTransactionsX(comparisonDateTransactions); + + await this.helper.RunTodaysTransactionsSummaryProcessing(comparisonDate.Date); + await this.helper.RunHistoricTransactionsSummaryProcessing(comparisonDate.Date); + await this.helper.RunTodaysTransactionsSummaryProcessing(todaysDateTime.Date); + + var result = await this.CreateAndSendHttpRequestMessage>($"{this.BaseRoute}/todayssalesbyhour?comparisondate={comparisonDate:yyyy-MM-dd}", CancellationToken.None); + result.IsSuccess.ShouldBeTrue(); + var todaysSalesByHour = result.Data; + todaysSalesByHour.ShouldNotBeNull(); + + foreach (var hour in todaysSalesByHour) { + hour.ShouldNotBeNull(); + hour.TodaysSalesCount.ShouldBe(todaysTransactions.Count(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay), hour.Hour.ToString()); + hour.TodaysSalesValue.ShouldBe(todaysTransactions.Where(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay).Sum(t => t.TransactionAmount), hour.Hour.ToString()); + hour.ComparisonSalesCount.ShouldBe(comparisonDateTransactions.Count(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay), hour.Hour.ToString()); + hour.ComparisonSalesValue.ShouldBe(comparisonDateTransactions.Where(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay).Sum(t => t.TransactionAmount), hour.Hour.ToString()); + + } } } \ No newline at end of file diff --git a/EstateReportingAPI.Models/TodaysSalesByHour.cs b/EstateReportingAPI.Models/TodaysSalesByHour.cs new file mode 100644 index 0000000..6a6974c --- /dev/null +++ b/EstateReportingAPI.Models/TodaysSalesByHour.cs @@ -0,0 +1,10 @@ +namespace EstateReportingAPI.Models; + +public class TodaysSalesByHour +{ + public Int32 Hour { get; set; } + public Decimal TodaysSalesValue { get; set; } + public Decimal ComparisonSalesValue { get; set; } + public Int32 TodaysSalesCount { get; set; } + public Int32 ComparisonSalesCount { get; set; } +} \ No newline at end of file diff --git a/EstateReportingAPI/Bootstrapper/MediatorRegistry.cs b/EstateReportingAPI/Bootstrapper/MediatorRegistry.cs index ad30299..a5ce106 100644 --- a/EstateReportingAPI/Bootstrapper/MediatorRegistry.cs +++ b/EstateReportingAPI/Bootstrapper/MediatorRegistry.cs @@ -25,6 +25,7 @@ public MediatorRegistry() { this.AddSingleton>, TransactionRequestHandler>(); this.AddSingleton>, TransactionRequestHandler>(); this.AddSingleton>, TransactionRequestHandler>(); + this.AddSingleton>>, TransactionRequestHandler>(); this.AddSingleton>>, CalendarRequestHandler>(); this.AddSingleton>>, CalendarRequestHandler>(); @@ -44,3 +45,4 @@ public MediatorRegistry() { } } + diff --git a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs index 234554f..245cb21 100644 --- a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs +++ b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs @@ -30,6 +30,8 @@ public static void MapTransactionEndpoints(this IEndpointRouteBuilder app) .WithStandardProduces(); group.MapGet("productperformancereport", TransactionHandler.ProductPerformanceReport) .WithStandardProduces(); + group.MapGet("todayssalesbyhour", TransactionHandler.TodaysSalesByHour) + .WithStandardProduces>(); } } \ No newline at end of file diff --git a/EstateReportingAPI/Handlers/TransactionHandler.cs b/EstateReportingAPI/Handlers/TransactionHandler.cs index 689a2ad..bbab578 100644 --- a/EstateReportingAPI/Handlers/TransactionHandler.cs +++ b/EstateReportingAPI/Handlers/TransactionHandler.cs @@ -181,4 +181,27 @@ TransactionSummaryByOperatorResponse SuccessFactory(Models.TransactionSummaryByO }; return ResponseFactory.FromResult(result, SuccessFactory); } + + public static async Task TodaysSalesByHour([FromHeader] Guid estateId, + [FromQuery] DateTime comparisonDate, + IMediator mediator, + CancellationToken cancellationToken) + { + var query = new TransactionQueries.TodaysSalesByHour(estateId, comparisonDate); + var result = await mediator.Send(query, cancellationToken); + + List SuccessFactory(List r) => + r.Select(item => new DataTransferObjects.TodaysSalesByHour + { + Hour = item.Hour, + ComparisonSalesCount = item.ComparisonSalesCount, + TodaysSalesCount = item.TodaysSalesCount, + ComparisonSalesValue = item.ComparisonSalesValue, + TodaysSalesValue = item.TodaysSalesValue + }).ToList(); + + + return ResponseFactory.FromResult(result, SuccessFactory); + } + } \ No newline at end of file diff --git a/EstateReportingAPI/Handlers/archivecode/FactTransactionsHandler.cs b/EstateReportingAPI/Handlers/archivecode/FactTransactionsHandler.cs index 123fc39..5d529f0 100644 --- a/EstateReportingAPI/Handlers/archivecode/FactTransactionsHandler.cs +++ b/EstateReportingAPI/Handlers/archivecode/FactTransactionsHandler.cs @@ -14,8 +14,6 @@ using SortDirection = EstateReportingAPI.DataTransferObjects.SortDirection; using SortField = EstateReportingAPI.DataTransferObjects.SortField; using TodaysSales = EstateReportingAPI.DataTransferObjects.TodaysSales; -using TodaysSalesCountByHour = EstateReportingAPI.DataTransferObjects.TodaysSalesCountByHour; -using TodaysSalesValueByHour = EstateReportingAPI.DataTransferObjects.TodaysSalesValueByHour; using TodaysSettlement = EstateReportingAPI.DataTransferObjects.TodaysSettlement; using TopBottom = EstateReportingAPI.DataTransferObjects.TopBottom; using TransactionResult = EstateReportingAPI.DataTransferObjects.TransactionResult;