From f5bcf867c32078a903ea285bacbe0b9317a42e78 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 29 Jun 2026 21:02:37 +0100 Subject: [PATCH 1/2] Add merchant daily performance summary endpoint Introduced a new API endpoint to retrieve a merchant's daily performance summary, including key metrics and the last five sales. Added supporting DTOs, query handlers, and LINQ logic for aggregation. Updated tests and endpoint mappings. Also applied minor code style improvements in MerchantHandler. --- .../Queries/TransactionQueries.cs | 1 + .../ReportingManager.cs | 147 ++++++++++++++++++ .../TransactionRequestHandler.cs | 8 +- .../MerchantDailyPerformanceSummaryRequest.cs | 44 ++++++ .../ControllerTestsBase.cs | 2 +- .../TransactionsEndpointTests.cs | 63 +++++++- .../MerchantDailyPerformanceSummaryRequest.cs | 41 +++++ .../Endpoints/TransactionEndpoints.cs | 6 +- .../Handlers/MerchantHandler.cs | 72 +++------ .../Handlers/TransactionHandler.cs | 38 ++++- 10 files changed, 363 insertions(+), 59 deletions(-) create mode 100644 EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs create mode 100644 EstateReportingAPI.Models/MerchantDailyPerformanceSummaryRequest.cs diff --git a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs index f2873b8..ac97904 100644 --- a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs +++ b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs @@ -14,4 +14,5 @@ public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummar 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>>; + public record MerchantDailyPerformanceSummaryQuery(Guid EstateId, MerchantDailyPerformanceSummaryRequest Request) : IRequest>; } \ No newline at end of file diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index 9723b15..50dee06 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -65,6 +65,9 @@ Task> GetFileImportLog(FileImportLogQueries.GetFileImportL CancellationToken cancellationToken); Task>> GetFileProfileConfigurationList(FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery request, CancellationToken cancellationToken); + + Task> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + CancellationToken cancellationToken); } public class ReportingManager : IReportingManager { @@ -1550,6 +1553,134 @@ join o in context.Operators on c.OperatorId equals o.OperatorId return Result.Success(result.Data); } + public async Task> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + DateTime startDate = request.Request.StartDate.Date; + DateTime endDate = request.Request.EndDate.Date; + int dayCount = Math.Max((endDate - startDate).Days + 1, 1); + + IQueryable groupedTransactionsQuery = + from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + where t.TransactionType == "Sale" + && t.TransactionDate >= startDate + && t.TransactionDate <= endDate + && m.MerchantReportingId == request.Request.MerchantReportingId + group t by new + { + cp.ContractProductReportingId, + cp.ProductName, + t.IsAuthorised + } + into g + select new MerchantDailyPerformanceGroupProjection + { + ContractProductReportingId = g.Key.ContractProductReportingId, + ProductName = g.Key.ProductName, + IsAuthorised = g.Key.IsAuthorised, + SalesCount = g.Count(), + SalesValue = g.Sum(x => x.TransactionAmount) + }; + + var groupedTransactionsResult = await ExecuteQuerySafeToList(groupedTransactionsQuery, cancellationToken, "Error retrieving merchant daily performance summary"); + if (groupedTransactionsResult.IsFailed) + return ResultHelpers.CreateFailure(groupedTransactionsResult); + + var groupedTransactions = groupedTransactionsResult.Data; + if (groupedTransactions.Any() == false) { + return Result.Success(new MerchantDailyPerformanceSummaryResponse { + Metrics = new List { + new() { Title = "Total Sales Count", Value = 0, Description = "All sales transactions in the range", Category = 0 }, + new() { Title = "Total Sales Value", Value = 0, Description = "All sales value in the range", Category = 1 }, + new() { Title = "Successful Sales Count", Value = 0, Description = "Authorised sales count in the range", Category = 2 }, + new() { Title = "Successful Sales Value", Value = 0, Description = "Authorised sales value in the range", Category = 3 }, + new() { Title = "Failed Sales Count", Value = 0, Description = "Declined sales count in the range", Category = 4 }, + new() { Title = "Failed Sales Value", Value = 0, Description = "Declined sales value in the range", Category = 5 }, + new() { Title = "Average Sales Count", Value = 0, Description = "Average sales count per day in the range", Category = 6 }, + new() { Title = "Average Sales Value", Value = 0, Description = "Average value per sale in the range", Category = 7 } + } + }); + } + + int totalSalesCount = groupedTransactions.Sum(x => x.SalesCount); + decimal totalSalesValue = groupedTransactions.Sum(x => x.SalesValue); + int successfulSalesCount = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesCount); + decimal successfulSalesValue = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesValue); + int failedSalesCount = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesCount); + decimal failedSalesValue = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesValue); + decimal averageSalesCount = (decimal)totalSalesCount / dayCount; + decimal averageSalesValue = totalSalesCount == 0 ? 0m : totalSalesValue / totalSalesCount; + + var topProduct = groupedTransactions + .GroupBy(x => new { x.ContractProductReportingId, x.ProductName }) + .Select(g => new { + g.Key.ContractProductReportingId, + g.Key.ProductName, + SalesCount = g.Sum(x => x.SalesCount), + SalesValue = g.Sum(x => x.SalesValue) + }) + .OrderByDescending(x => x.SalesCount) + .ThenBy(x => x.ProductName) + .FirstOrDefault(); + + List metrics = new() { + new() { Title = "Total Sales Count", Value = totalSalesCount, Description = "All sales transactions in the range", Category = 0 }, + new() { Title = "Total Sales Value", Value = totalSalesValue, Description = "All sales value in the range", Category = 1 }, + new() { Title = "Successful Sales Count", Value = successfulSalesCount, Description = "Authorised sales count in the range", Category = 2 }, + new() { Title = "Successful Sales Value", Value = successfulSalesValue, Description = "Authorised sales value in the range", Category = 3 }, + new() { Title = "Failed Sales Count", Value = failedSalesCount, Description = "Declined sales count in the range", Category = 4 }, + new() { Title = "Failed Sales Value", Value = failedSalesValue, Description = "Declined sales value in the range", Category = 5 }, + new() { Title = "Average Sales Count", Value = averageSalesCount, Description = "Average sales count per day in the range", Category = 6 }, + new() { Title = "Average Sales Value", Value = averageSalesValue, Description = "Average value per sale in the range", Category = 7 } + }; + + if (topProduct != null) { + metrics.Add(new MetricItem { + Title = "Top Product Sales Count", + Value = topProduct.SalesCount, + Description = topProduct.ProductName ?? "Unknown product", + Category = 8 + }); + } + + IQueryable recentSalesQuery = + (from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + where t.TransactionType == "Sale" + && t.TransactionDate >= startDate + && t.TransactionDate <= endDate + && m.MerchantReportingId == request.Request.MerchantReportingId + orderby t.TransactionDateTime descending + select new MerchantDailyPerformanceRecentSaleProjection + { + Reference = t.TransactionNumber, + Product = cp.ProductName, + Status = t.IsAuthorised ? "Successful" : "Failed", + Amount = t.TransactionAmount, + TransactionDateTime = t.TransactionDateTime + }).Take(5); + + var recentSalesResult = await ExecuteQuerySafeToList(recentSalesQuery, cancellationToken, "Error retrieving merchant recent sales"); + if (recentSalesResult.IsFailed) + return ResultHelpers.CreateFailure(recentSalesResult); + + return Result.Success(new MerchantDailyPerformanceSummaryResponse { + Metrics = metrics, + DrillDownTransactions = recentSalesResult.Data.Select(x => new DrillDownTransaction { + Reference = x.Reference, + Product = x.Product, + Status = x.Status, + Amount = x.Amount, + TransactionDateTime = x.TransactionDateTime + }).ToList() + }); + } + private async Task GetSettlementSummary(IQueryable query, CancellationToken cancellationToken) { // Get the settleed fees summary @@ -1711,6 +1842,22 @@ private sealed class MerchantTransactionFinalProjection { public decimal AuthorisedPercentage { get; init; } } + private sealed class MerchantDailyPerformanceGroupProjection { + public int ContractProductReportingId { get; init; } + public string? ProductName { get; init; } + public bool IsAuthorised { get; init; } + public int SalesCount { get; init; } + public decimal SalesValue { get; init; } + } + + private sealed class MerchantDailyPerformanceRecentSaleProjection { + public string Reference { get; init; } + public string? Product { get; init; } + public string Status { get; init; } + public decimal Amount { get; init; } + public DateTime TransactionDateTime { get; init; } + } + private sealed class TransactionDetailQueryResult { public Guid TransactionId { get; init; } public DateTime TransactionDateTime { get; init; } diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs index 04ae197..a94f2dc 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs @@ -11,7 +11,8 @@ public class TransactionRequestHandler : IRequestHandler>, IRequestHandler>, IRequestHandler>, - IRequestHandler>> + IRequestHandler>>, + IRequestHandler> { private readonly IReportingManager Manager; @@ -56,4 +57,9 @@ public async Task>> Handle(TransactionQueries.Tod CancellationToken cancellationToken) { return await this.Manager.GetTodaysSalesByHour(request, cancellationToken); } + + public async Task> Handle(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + CancellationToken cancellationToken) { + return await this.Manager.GetMerchantDailyPerformanceSummary(request, cancellationToken); + } } \ No newline at end of file diff --git a/EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs b/EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs new file mode 100644 index 0000000..e0bd88b --- /dev/null +++ b/EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace EstateReportingAPI.DataTransferObjects +{ + public class MerchantDailyPerformanceSummaryRequest + { + public Int32 MerchantReportingId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + } + + public sealed class MerchantDailyPerformanceSummaryResponse + { + public List Metrics { get; set; } = []; + + public List DrillDownTransactions { get; set; } = []; + } + + public sealed class MetricItem + { + public string Title { get; set; } + + public Decimal Value { get; set; } + + public string Description { get; set; } + + public int Category { get; set; } + } + + public sealed class DrillDownTransaction + { + public string Reference { get; set; } + + public string Product { get; set; } + + public string Status { get; set; } + + public Decimal Amount { get; set; } + + public DateTime TransactionDateTime { get; set; } + } +} diff --git a/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs b/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs index 352ab8d..0a559ab 100644 --- a/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs +++ b/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs @@ -169,4 +169,4 @@ public override async Task CreateSubscriptions(){ return sqlContainer.Item2.GetMappedPublicPort(1433); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs index 054a97b..8d42947 100644 --- a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs @@ -1071,4 +1071,65 @@ public async Task TransactionsEndpoint_TodaysSalesByHour_SummaryDataReturned() } } -} \ No newline at end of file + + [Fact] + public async Task TransactionsEndpoint_MerchantDailyPerformanceSummary_ReturnsMetricsAndLastFiveSales() + { + TransactionProcessor.Database.Entities.Merchant merchant = this.merchantsList.Single(m => m.Name == "Test Merchant 1"); + var contract = this.contractList.Single(c => c.contractName == "Safaricom Contract"); + var product = this.contractProducts.Single(cp => cp.Key == contract.contractId).Value.First(); + + DateTime transactionDate = DateTime.Now.Date; + List transactions = new(); + + for (int i = 1; i <= 6; i++) + { + DateTime transactionDateTime = transactionDate.AddHours(9 + i); + string responseCode = i % 2 == 0 ? "1009" : "0000"; + + Transaction transaction = await this.helper.BuildTransactionX( + transactionDateTime, + merchant.MerchantId, + contract.operatorId, + contract.contractId, + product.productId, + responseCode, + product.productValue, + i); + + transactions.Add(transaction); + } + + await this.helper.AddTransactionsX(transactions); + + MerchantDailyPerformanceSummaryRequest request = new() + { + MerchantReportingId = merchant.MerchantReportingId, + StartDate = transactionDate, + EndDate = transactionDate + }; + + var result = await this.CreateAndSendHttpRequestMessage( + $"{this.BaseRoute}/merchantdailyperformancesummary", + StringSerialiser.Serialise(request), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + + MerchantDailyPerformanceSummaryResponse response = result.Data; + response.ShouldNotBeNull(); + response.Metrics.ShouldNotBeNull(); + response.DrillDownTransactions.ShouldNotBeNull(); + + response.Metrics.Count.ShouldBe(9); + response.Metrics.Single(m => m.Title == "Total Sales Count").Value.ShouldBe(6); + response.Metrics.Single(m => m.Title == "Successful Sales Count").Value.ShouldBe(3); + response.Metrics.Single(m => m.Title == "Failed Sales Count").Value.ShouldBe(3); + response.Metrics.Single(m => m.Title == "Top Product Sales Count").Value.ShouldBe(6); + response.Metrics.Single(m => m.Title == "Top Product Sales Count").Description.ShouldBe(product.productName); + + response.DrillDownTransactions.Count.ShouldBe(5); + response.DrillDownTransactions.Select(x => x.Reference).ShouldBe(new[] { "0006", "0005", "0004", "0003", "0002" }); + response.DrillDownTransactions.Select(x => x.Status).ShouldBe(new[] { "Failed", "Successful", "Failed", "Successful", "Failed" }); + } +} diff --git a/EstateReportingAPI.Models/MerchantDailyPerformanceSummaryRequest.cs b/EstateReportingAPI.Models/MerchantDailyPerformanceSummaryRequest.cs new file mode 100644 index 0000000..3c19ec5 --- /dev/null +++ b/EstateReportingAPI.Models/MerchantDailyPerformanceSummaryRequest.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace EstateReportingAPI.Models +{ + public class MerchantDailyPerformanceSummaryRequest + { + public Int32 MerchantReportingId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + } + + public class MerchantDailyPerformanceSummaryResponse { + public List Metrics { get; set; } = []; + + public List DrillDownTransactions { get; set; } = []; + } + + public class MetricItem { + public string Title { get; set; } + + public Decimal Value { get; set; } + + public string Description { get; set; } + + public Int32 Category { get; set; } + } + + public class DrillDownTransaction { + public string Reference { get; set; } + + public string Product { get; set; } + + public string Status { get; set; } + + public Decimal Amount { get; set; } + + public DateTime TransactionDateTime { get; set; } + } +} diff --git a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs index 245cb21..4ecc736 100644 --- a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs +++ b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs @@ -32,6 +32,10 @@ public static void MapTransactionEndpoints(this IEndpointRouteBuilder app) .WithStandardProduces(); group.MapGet("todayssalesbyhour", TransactionHandler.TodaysSalesByHour) .WithStandardProduces>(); + group.MapPost("merchantdailyperformancesummary", TransactionHandler.MerchantDailyPerformanceSummary) + .WithStandardProduces(); + group.MapPost("dailymerchaantprformancesummary", TransactionHandler.MerchantDailyPerformanceSummary) + .WithStandardProduces(); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI/Handlers/MerchantHandler.cs b/EstateReportingAPI/Handlers/MerchantHandler.cs index 54f85cb..d3238c1 100644 --- a/EstateReportingAPI/Handlers/MerchantHandler.cs +++ b/EstateReportingAPI/Handlers/MerchantHandler.cs @@ -7,21 +7,13 @@ namespace EstateReportingAPI.Handlers; -public static class MerchantHandler -{ +public static class MerchantHandler { public static async Task GetMerchantKpis([FromHeader] Guid estateId, IMediator mediator, - CancellationToken cancellationToken) - { + CancellationToken cancellationToken) { MerchantQueries.GetTransactionKpisQuery query = new(estateId); Result result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => - new DataTransferObjects.MerchantKpi() - { - MerchantsWithNoSaleInLast7Days =r.MerchantsWithNoSaleInLast7Days, - MerchantsWithNoSaleToday = r.MerchantsWithNoSaleToday, - MerchantsWithSaleInLastHour = r.MerchantsWithSaleInLastHour - }); + return ResponseFactory.FromResult(result, r => new DataTransferObjects.MerchantKpi() { MerchantsWithNoSaleInLast7Days = r.MerchantsWithNoSaleInLast7Days, MerchantsWithNoSaleToday = r.MerchantsWithNoSaleToday, MerchantsWithSaleInLastHour = r.MerchantsWithSaleInLastHour }); } public static async Task GetRecentMerchants([FromHeader] Guid estateId, @@ -29,8 +21,7 @@ public static async Task GetRecentMerchants([FromHeader] Guid estateId, CancellationToken cancellationToken) { MerchantQueries.GetRecentMerchantsQuery query = new(estateId); Result> result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => r.Select(m => new EstateReportingAPI.DataTransferObjects.Merchant - { + return ResponseFactory.FromResult(result, r => r.Select(m => new EstateReportingAPI.DataTransferObjects.Merchant { MerchantReportingId = m.MerchantReportingId, MerchantId = m.MerchantId, Name = m.Name, @@ -47,7 +38,6 @@ public static async Task GetRecentMerchants([FromHeader] Guid estateId, ContactName = m.ContactName, ContactEmail = m.ContactEmail, ContactPhone = m.ContactPhone - }).OrderByDescending(m => m.CreatedDateTime).ToList()); } @@ -58,15 +48,13 @@ public static async Task GetMerchants([FromHeader] Guid estateId, [FromQuery] String? region, [FromQuery] String? postCode, IMediator mediator, - CancellationToken cancellationToken) - { + CancellationToken cancellationToken) { // Build the query options MerchantQueries.MerchantQueryOptions queryOptions = new(name, reference, settlementSchedule, region, postCode); MerchantQueries.GetMerchantsQuery query = new(estateId, queryOptions); Result> result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => r.Select(m => new EstateReportingAPI.DataTransferObjects.Merchant - { + return ResponseFactory.FromResult(result, r => r.Select(m => new EstateReportingAPI.DataTransferObjects.Merchant { MerchantReportingId = m.MerchantReportingId, MerchantId = m.MerchantId, Name = m.Name, @@ -89,14 +77,12 @@ public static async Task GetMerchants([FromHeader] Guid estateId, public static async Task GetMerchant([FromHeader] Guid estateId, [FromRoute] Guid merchantId, IMediator mediator, - CancellationToken cancellationToken) - { + CancellationToken cancellationToken) { // Build the query options MerchantQueries.GetMerchantQuery query = new(estateId, merchantId); Result result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => new EstateReportingAPI.DataTransferObjects.Merchant - { + return ResponseFactory.FromResult(result, r => new EstateReportingAPI.DataTransferObjects.Merchant { MerchantReportingId = r.MerchantReportingId, MerchantId = r.MerchantId, Name = r.Name, @@ -140,20 +126,17 @@ public static async Task GetMerchantOperators([FromHeader] Guid estateI public static async Task GetMerchantContracts([FromHeader] Guid estateId, [FromRoute] Guid merchantId, IMediator mediator, - CancellationToken cancellationToken) - { + CancellationToken cancellationToken) { MerchantQueries.GetMerchantContractsQuery query = new(estateId, merchantId); Result> result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => r.Select(c => new DataTransferObjects.MerchantContract() - { + return ResponseFactory.FromResult(result, r => r.Select(c => new DataTransferObjects.MerchantContract() { ContractId = c.ContractId, ContractName = c.ContractName, OperatorName = c.OperatorName, IsDeleted = c.IsDeleted, MerchantId = c.MerchantId, - ContractProducts = c.ContractProducts.Select(p => new DataTransferObjects.MerchantContractProduct() - { + ContractProducts = c.ContractProducts.Select(p => new DataTransferObjects.MerchantContractProduct() { ProductId = p.ProductId, ProductName = p.ProductName, ContractId = c.ContractId, @@ -166,34 +149,21 @@ public static async Task GetMerchantContracts([FromHeader] Guid estateI public static async Task GetMerchantDevices([FromHeader] Guid estateId, [FromRoute] Guid merchantId, IMediator mediator, - CancellationToken cancellationToken) - { + CancellationToken cancellationToken) { MerchantQueries.GetMerchantDevicesQuery query = new(estateId, merchantId); Result> result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => r.Select(c => new DataTransferObjects.MerchantDevice() - { - IsDeleted = c.IsDeleted, - MerchantId = c.MerchantId, - DeviceId = c.DeviceId, - DeviceIdentifier = c.DeviceIdentifier - }).ToList()); + return ResponseFactory.FromResult(result, r => r.Select(c => new DataTransferObjects.MerchantDevice() { IsDeleted = c.IsDeleted, MerchantId = c.MerchantId, DeviceId = c.DeviceId, DeviceIdentifier = c.DeviceIdentifier }).ToList()); } public static async Task GetMerchantOpeningHours([FromHeader] Guid estateId, - [FromRoute] Guid merchantId, - IMediator mediator, - CancellationToken cancellationToken) { + [FromRoute] Guid merchantId, + IMediator mediator, + CancellationToken cancellationToken) { MerchantQueries.GetMerchantOpeningHoursQuery query = new(estateId, merchantId); Result> result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => r.Select(c => new DataTransferObjects.MerchantOpeningHour() - { - MerchantId = c.MerchantId, - DayOfWeek = c.DayOfWeek, - OpeningTime = c.OpeningTime, - ClosingTime = c.ClosingTime - }).ToList()); + return ResponseFactory.FromResult(result, r => r.Select(c => new DataTransferObjects.MerchantOpeningHour() { MerchantId = c.MerchantId, DayOfWeek = c.DayOfWeek, OpeningTime = c.OpeningTime, ClosingTime = c.ClosingTime }).ToList()); } public static async Task GetMerchantSchedule([FromHeader] Guid estateId, @@ -204,12 +174,6 @@ public static async Task GetMerchantSchedule([FromHeader] Guid estateId MerchantQueries.GetMerchantScheduleQuery query = new(estateId, merchantId, year); Result result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => new DataTransferObjects.MerchantScheduleResponse() { - Year = r.Year, - Months = r.Months.Select(m => new DataTransferObjects.MerchantScheduleMonthResponse() { - Month = m.Month, - ClosedDays = m.ClosedDays - }).ToList() - }); + return ResponseFactory.FromResult(result, r => new DataTransferObjects.MerchantScheduleResponse() { Year = r.Year, Months = r.Months.Select(m => new DataTransferObjects.MerchantScheduleMonthResponse() { Month = m.Month, ClosedDays = m.ClosedDays }).ToList() }); } } \ No newline at end of file diff --git a/EstateReportingAPI/Handlers/TransactionHandler.cs b/EstateReportingAPI/Handlers/TransactionHandler.cs index 328ce9f..7ec3e19 100644 --- a/EstateReportingAPI/Handlers/TransactionHandler.cs +++ b/EstateReportingAPI/Handlers/TransactionHandler.cs @@ -3,6 +3,8 @@ using MediatR; using Microsoft.AspNetCore.Mvc; using Shared.Results.Web; +using SimpleResults; +using TransactionProcessor.Database.Entities.Summary; using TodaysSales = EstateReportingAPI.Models.TodaysSales; namespace EstateReportingAPI.Handlers; @@ -205,4 +207,38 @@ public static async Task TodaysSalesByHour([FromHeader] Guid estateId, return ResponseFactory.FromResult(result, SuccessFactory); } -} \ No newline at end of file + public static async Task MerchantDailyPerformanceSummary([FromHeader] Guid estateId, + [FromBody] MerchantDailyPerformanceSummaryRequest request, + IMediator mediator, CancellationToken cancellationToken) + { + TransactionQueries.MerchantDailyPerformanceSummaryQuery query = new(estateId, new Models.MerchantDailyPerformanceSummaryRequest { + EndDate = request.EndDate, + MerchantReportingId = request.MerchantReportingId, + StartDate = request.StartDate + }); + Result result = await mediator.Send(query, cancellationToken); + + MerchantDailyPerformanceSummaryResponse SuccessFactory(Models.MerchantDailyPerformanceSummaryResponse r) => + new MerchantDailyPerformanceSummaryResponse + { + Metrics = r.Metrics?.Select(metric => new MetricItem + { + Title = metric.Title, + Value = metric.Value, + Description = metric.Description, + Category = metric.Category + }).ToList() ?? [], + DrillDownTransactions = r.DrillDownTransactions?.Select(transaction => new DrillDownTransaction + { + Reference = transaction.Reference, + Product = transaction.Product, + Status = transaction.Status, + Amount = transaction.Amount, + TransactionDateTime = transaction.TransactionDateTime + }).ToList() ?? [] + }; + + return ResponseFactory.FromResult(result, SuccessFactory); + } +} + From 49b5d2cbf68b50db75c0aecc10e2e71f3b1bbc4b Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 30 Jun 2026 08:11:38 +0100 Subject: [PATCH 2/2] fix codacy issues --- .../ReportingManager.cs | 248 ++++++++++-------- 1 file changed, 145 insertions(+), 103 deletions(-) diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index 50dee06..bc3a79a 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -1562,122 +1562,24 @@ public async Task> GetMerchantDa DateTime endDate = request.Request.EndDate.Date; int dayCount = Math.Max((endDate - startDate).Days + 1, 1); - IQueryable groupedTransactionsQuery = - from t in context.Transactions - join m in context.Merchants on t.MerchantId equals m.MerchantId - join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - where t.TransactionType == "Sale" - && t.TransactionDate >= startDate - && t.TransactionDate <= endDate - && m.MerchantReportingId == request.Request.MerchantReportingId - group t by new - { - cp.ContractProductReportingId, - cp.ProductName, - t.IsAuthorised - } - into g - select new MerchantDailyPerformanceGroupProjection - { - ContractProductReportingId = g.Key.ContractProductReportingId, - ProductName = g.Key.ProductName, - IsAuthorised = g.Key.IsAuthorised, - SalesCount = g.Count(), - SalesValue = g.Sum(x => x.TransactionAmount) - }; - - var groupedTransactionsResult = await ExecuteQuerySafeToList(groupedTransactionsQuery, cancellationToken, "Error retrieving merchant daily performance summary"); + var groupedTransactionsResult = await LoadMerchantDailyPerformanceGroups(context, request, startDate, endDate, cancellationToken); if (groupedTransactionsResult.IsFailed) return ResultHelpers.CreateFailure(groupedTransactionsResult); var groupedTransactions = groupedTransactionsResult.Data; if (groupedTransactions.Any() == false) { - return Result.Success(new MerchantDailyPerformanceSummaryResponse { - Metrics = new List { - new() { Title = "Total Sales Count", Value = 0, Description = "All sales transactions in the range", Category = 0 }, - new() { Title = "Total Sales Value", Value = 0, Description = "All sales value in the range", Category = 1 }, - new() { Title = "Successful Sales Count", Value = 0, Description = "Authorised sales count in the range", Category = 2 }, - new() { Title = "Successful Sales Value", Value = 0, Description = "Authorised sales value in the range", Category = 3 }, - new() { Title = "Failed Sales Count", Value = 0, Description = "Declined sales count in the range", Category = 4 }, - new() { Title = "Failed Sales Value", Value = 0, Description = "Declined sales value in the range", Category = 5 }, - new() { Title = "Average Sales Count", Value = 0, Description = "Average sales count per day in the range", Category = 6 }, - new() { Title = "Average Sales Value", Value = 0, Description = "Average value per sale in the range", Category = 7 } - } - }); + return Result.Success(BuildEmptyMerchantDailyPerformanceSummaryResponse()); } - int totalSalesCount = groupedTransactions.Sum(x => x.SalesCount); - decimal totalSalesValue = groupedTransactions.Sum(x => x.SalesValue); - int successfulSalesCount = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesCount); - decimal successfulSalesValue = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesValue); - int failedSalesCount = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesCount); - decimal failedSalesValue = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesValue); - decimal averageSalesCount = (decimal)totalSalesCount / dayCount; - decimal averageSalesValue = totalSalesCount == 0 ? 0m : totalSalesValue / totalSalesCount; - - var topProduct = groupedTransactions - .GroupBy(x => new { x.ContractProductReportingId, x.ProductName }) - .Select(g => new { - g.Key.ContractProductReportingId, - g.Key.ProductName, - SalesCount = g.Sum(x => x.SalesCount), - SalesValue = g.Sum(x => x.SalesValue) - }) - .OrderByDescending(x => x.SalesCount) - .ThenBy(x => x.ProductName) - .FirstOrDefault(); - - List metrics = new() { - new() { Title = "Total Sales Count", Value = totalSalesCount, Description = "All sales transactions in the range", Category = 0 }, - new() { Title = "Total Sales Value", Value = totalSalesValue, Description = "All sales value in the range", Category = 1 }, - new() { Title = "Successful Sales Count", Value = successfulSalesCount, Description = "Authorised sales count in the range", Category = 2 }, - new() { Title = "Successful Sales Value", Value = successfulSalesValue, Description = "Authorised sales value in the range", Category = 3 }, - new() { Title = "Failed Sales Count", Value = failedSalesCount, Description = "Declined sales count in the range", Category = 4 }, - new() { Title = "Failed Sales Value", Value = failedSalesValue, Description = "Declined sales value in the range", Category = 5 }, - new() { Title = "Average Sales Count", Value = averageSalesCount, Description = "Average sales count per day in the range", Category = 6 }, - new() { Title = "Average Sales Value", Value = averageSalesValue, Description = "Average value per sale in the range", Category = 7 } - }; + List metrics = BuildMerchantDailyPerformanceMetrics(groupedTransactions, dayCount); - if (topProduct != null) { - metrics.Add(new MetricItem { - Title = "Top Product Sales Count", - Value = topProduct.SalesCount, - Description = topProduct.ProductName ?? "Unknown product", - Category = 8 - }); - } - - IQueryable recentSalesQuery = - (from t in context.Transactions - join m in context.Merchants on t.MerchantId equals m.MerchantId - join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - where t.TransactionType == "Sale" - && t.TransactionDate >= startDate - && t.TransactionDate <= endDate - && m.MerchantReportingId == request.Request.MerchantReportingId - orderby t.TransactionDateTime descending - select new MerchantDailyPerformanceRecentSaleProjection - { - Reference = t.TransactionNumber, - Product = cp.ProductName, - Status = t.IsAuthorised ? "Successful" : "Failed", - Amount = t.TransactionAmount, - TransactionDateTime = t.TransactionDateTime - }).Take(5); - - var recentSalesResult = await ExecuteQuerySafeToList(recentSalesQuery, cancellationToken, "Error retrieving merchant recent sales"); + var recentSalesResult = await LoadMerchantDailyPerformanceRecentSales(context, request, startDate, endDate, cancellationToken); if (recentSalesResult.IsFailed) return ResultHelpers.CreateFailure(recentSalesResult); return Result.Success(new MerchantDailyPerformanceSummaryResponse { Metrics = metrics, - DrillDownTransactions = recentSalesResult.Data.Select(x => new DrillDownTransaction { - Reference = x.Reference, - Product = x.Product, - Status = x.Status, - Amount = x.Amount, - TransactionDateTime = x.TransactionDateTime - }).ToList() + DrillDownTransactions = MapMerchantDailyPerformanceRecentSales(recentSalesResult.Data) }); } @@ -1858,6 +1760,146 @@ private sealed class MerchantDailyPerformanceRecentSaleProjection { public DateTime TransactionDateTime { get; init; } } + private static MerchantDailyPerformanceSummaryResponse BuildEmptyMerchantDailyPerformanceSummaryResponse() { + return new MerchantDailyPerformanceSummaryResponse { + Metrics = BuildMerchantDailyPerformanceBaseMetrics() + }; + } + + private static List BuildMerchantDailyPerformanceBaseMetrics() { + return new List { + new() { Title = "Total Sales Count", Value = 0, Description = "All sales transactions in the range", Category = 0 }, + new() { Title = "Total Sales Value", Value = 0, Description = "All sales value in the range", Category = 1 }, + new() { Title = "Successful Sales Count", Value = 0, Description = "Authorised sales count in the range", Category = 2 }, + new() { Title = "Successful Sales Value", Value = 0, Description = "Authorised sales value in the range", Category = 3 }, + new() { Title = "Failed Sales Count", Value = 0, Description = "Declined sales count in the range", Category = 4 }, + new() { Title = "Failed Sales Value", Value = 0, Description = "Declined sales value in the range", Category = 5 }, + new() { Title = "Average Sales Count", Value = 0, Description = "Average sales count per day in the range", Category = 6 }, + new() { Title = "Average Sales Value", Value = 0, Description = "Average value per sale in the range", Category = 7 } + }; + } + + private static List BuildMerchantDailyPerformanceMetrics(List groupedTransactions, + int dayCount) { + int totalSalesCount = groupedTransactions.Sum(x => x.SalesCount); + decimal totalSalesValue = groupedTransactions.Sum(x => x.SalesValue); + int successfulSalesCount = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesCount); + decimal successfulSalesValue = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesValue); + int failedSalesCount = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesCount); + decimal failedSalesValue = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesValue); + decimal averageSalesCount = (decimal)totalSalesCount / dayCount; + decimal averageSalesValue = totalSalesCount == 0 ? 0m : totalSalesValue / totalSalesCount; + + List metrics = new() { + new() { Title = "Total Sales Count", Value = totalSalesCount, Description = "All sales transactions in the range", Category = 0 }, + new() { Title = "Total Sales Value", Value = totalSalesValue, Description = "All sales value in the range", Category = 1 }, + new() { Title = "Successful Sales Count", Value = successfulSalesCount, Description = "Authorised sales count in the range", Category = 2 }, + new() { Title = "Successful Sales Value", Value = successfulSalesValue, Description = "Authorised sales value in the range", Category = 3 }, + new() { Title = "Failed Sales Count", Value = failedSalesCount, Description = "Declined sales count in the range", Category = 4 }, + new() { Title = "Failed Sales Value", Value = failedSalesValue, Description = "Declined sales value in the range", Category = 5 }, + new() { Title = "Average Sales Count", Value = averageSalesCount, Description = "Average sales count per day in the range", Category = 6 }, + new() { Title = "Average Sales Value", Value = averageSalesValue, Description = "Average value per sale in the range", Category = 7 } + }; + + MetricItem? topProductMetric = BuildTopProductMetric(groupedTransactions); + if (topProductMetric != null) + metrics.Add(topProductMetric); + + return metrics; + } + + private static MetricItem? BuildTopProductMetric(List groupedTransactions) { + var topProduct = groupedTransactions + .GroupBy(x => new { x.ContractProductReportingId, x.ProductName }) + .Select(g => new { + g.Key.ContractProductReportingId, + g.Key.ProductName, + SalesCount = g.Sum(x => x.SalesCount), + SalesValue = g.Sum(x => x.SalesValue) + }) + .OrderByDescending(x => x.SalesCount) + .ThenBy(x => x.ProductName) + .FirstOrDefault(); + + if (topProduct == null) + return null; + + return new MetricItem { + Title = "Top Product Sales Count", + Value = topProduct.SalesCount, + Description = topProduct.ProductName ?? "Unknown product", + Category = 8 + }; + } + + private async Task>> LoadMerchantDailyPerformanceGroups(EstateManagementContext context, + TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken) { + var query = + from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + where t.TransactionType == "Sale" + && t.TransactionDate >= startDate + && t.TransactionDate <= endDate + && m.MerchantReportingId == request.Request.MerchantReportingId + group t by new + { + cp.ContractProductReportingId, + cp.ProductName, + t.IsAuthorised + } + into g + select new MerchantDailyPerformanceGroupProjection + { + ContractProductReportingId = g.Key.ContractProductReportingId, + ProductName = g.Key.ProductName, + IsAuthorised = g.Key.IsAuthorised, + SalesCount = g.Count(), + SalesValue = g.Sum(x => x.TransactionAmount) + }; + + return await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving merchant daily performance summary"); + } + + private async Task>> LoadMerchantDailyPerformanceRecentSales(EstateManagementContext context, + TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken) { + var query = + (from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + where t.TransactionType == "Sale" + && t.TransactionDate >= startDate + && t.TransactionDate <= endDate + && m.MerchantReportingId == request.Request.MerchantReportingId + orderby t.TransactionDateTime descending + select new MerchantDailyPerformanceRecentSaleProjection + { + Reference = t.TransactionNumber, + Product = cp.ProductName, + Status = t.IsAuthorised ? "Successful" : "Failed", + Amount = t.TransactionAmount, + TransactionDateTime = t.TransactionDateTime + }).Take(5); + + return await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving merchant recent sales"); + } + + private static List MapMerchantDailyPerformanceRecentSales(List recentSales) { + return recentSales.Select(x => new DrillDownTransaction { + Reference = x.Reference, + Product = x.Product, + Status = x.Status, + Amount = x.Amount, + TransactionDateTime = x.TransactionDateTime + }).ToList(); + } + private sealed class TransactionDetailQueryResult { public Guid TransactionId { get; init; } public DateTime TransactionDateTime { get; init; }