From 00615bdb85153cce1e8ef3d10dfb38db694d5914 Mon Sep 17 00:00:00 2001 From: miris-mp Date: Mon, 9 Dec 2024 19:04:03 +0100 Subject: [PATCH 1/4] Enhance Instrument Statistics with AIM Filter Integration --- .../ApiServer/Controllers/StatsController.cs | 16 +++---- .../ApiServer/DTO/StatisticsRequestDto.cs | 7 +++ .../ApiServer/Service/GenerationService.cs | 44 ++++++++++++++++--- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/ApiServer/ApiServer/Controllers/StatsController.cs b/src/ApiServer/ApiServer/Controllers/StatsController.cs index 268d5e8..fcc149e 100644 --- a/src/ApiServer/ApiServer/Controllers/StatsController.cs +++ b/src/ApiServer/ApiServer/Controllers/StatsController.cs @@ -95,7 +95,7 @@ public async Task FetchVouchersGeneratedAndRedeemedStats([FromBody var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); // Call separate methods or services to retrieve stats - var response = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.Latitude, request.Longitude, request.Radius); + var response = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter, request.Latitude, request.Longitude, request.Radius); return Ok(response); } @@ -154,8 +154,7 @@ [FromBody] StatisticsRequestDto request // Fetch the total amount of generated and redeemed vouchers, passing the optional date range var generatedVouchers = - await GenerationService.FetchTotalVouchersGeneratedAndRedeemed(parsedStartDate, parsedEndDate, - request.SourceId); + await GenerationService.FetchTotalVouchersGeneratedAndRedeemed(parsedStartDate, parsedEndDate,request.SourceId, request.AimListFilter); // Return the JSON response @@ -233,7 +232,7 @@ [FromBody] StatisticsRequestDto request var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); var generatedVouchersByAim = - await GenerationService.FetchTotalVouchersGeneratedByAim(parsedStartDate, parsedEndDate, request.SourceId); + await GenerationService.FetchTotalVouchersGeneratedByAim(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter); return Ok(generatedVouchersByAim); } catch(ServiceProblemException e) { @@ -388,7 +387,7 @@ [FromBody] StatisticsRequestDto request var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); // Fetch the list of consumed vouchers based on aim - var totalGeneratedVouchersOverTime = await GenerationService.GetTotalGeneratedRedeemedVouchersOverTime(parsedStartDate, parsedEndDate, request.SourceId); + var totalGeneratedVouchersOverTime = await GenerationService.GetTotalGeneratedRedeemedVouchersOverTime(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter); return Ok(totalGeneratedVouchersOverTime); } @@ -424,20 +423,15 @@ [FromBody] StatisticsRequestDto request [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] public async Task DownloadCsv([FromBody] StatisticsRequestDto request) { - Console.WriteLine("Requests ", request.StartDate, request.EndDate); await VerifyUserIsAdmin(); // check if user is an admin // if dates present check dates are valid and in case parse them var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); // create general API to call them and save the data - var genRedResponse = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.Latitude, request.Longitude, request.Radius); + var genRedResponse = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter, request.Latitude, request.Longitude, request.Radius); var consumedResponse = await PaymentService.FetchTotalVouchersConsumedStats(parsedStartDate, parsedEndDate, request.MerchantId); - // flatten data - // save the data to put on the CSV - // use the CSV to save the data in a file - // send the file back var records = CsvFileHelper.GenerateCsvContent(genRedResponse, consumedResponse); return File(records, "text/csv", $"{DateTime.Now:yyyy-M-d dddd}_stats.csv"); diff --git a/src/ApiServer/ApiServer/DTO/StatisticsRequestDto.cs b/src/ApiServer/ApiServer/DTO/StatisticsRequestDto.cs index bee85d5..b577392 100644 --- a/src/ApiServer/ApiServer/DTO/StatisticsRequestDto.cs +++ b/src/ApiServer/ApiServer/DTO/StatisticsRequestDto.cs @@ -8,7 +8,14 @@ public class StatisticsRequestDto { public string EndDate { get; set; } public ObjectId? MerchantId { get; set; } public ObjectId? SourceId { get; set; } + + public string[] AimListFilter { get; set; } public double? Latitude { get; set; } public double? Longitude { get; set; } public int? Radius { get; set; } + + public override string ToString() { + return $"StartDate: {StartDate}, EndDate: {EndDate}, MerchantId: {MerchantId}, SourceId: {SourceId}, " + + $"AimListFilter: [{string.Join(", ", AimListFilter ?? new string[0])}], Latitude: {Latitude}, Longitude: {Longitude}, Radius: {Radius}"; + } } diff --git a/src/ApiServer/ApiServer/Service/GenerationService.cs b/src/ApiServer/ApiServer/Service/GenerationService.cs index 03f30cf..19bc7a8 100644 --- a/src/ApiServer/ApiServer/Service/GenerationService.cs +++ b/src/ApiServer/ApiServer/Service/GenerationService.cs @@ -269,13 +269,14 @@ public async Task FetchTotalVouchersGe DateTime? startDate, DateTime? endDate, ObjectId? sourceId, + string[] aimListFilter, double? latitude, double? longitude, int? radius ) { - var (generatedVouchers, redeemedVouchers) = await FetchTotalVouchersGeneratedAndRedeemed(startDate, endDate, sourceId); - List voucherByAim = await FetchTotalVouchersGeneratedByAim(startDate, endDate, sourceId); - List totalGeneratedRedeemedVouchersOverTime = await GetTotalGeneratedRedeemedVouchersOverTime(startDate, endDate, sourceId); + var (generatedVouchers, redeemedVouchers) = await FetchTotalVouchersGeneratedAndRedeemed(startDate, endDate, sourceId, aimListFilter); + List voucherByAim = await FetchTotalVouchersGeneratedByAim(startDate, endDate, sourceId, aimListFilter); + List totalGeneratedRedeemedVouchersOverTime = await GetTotalGeneratedRedeemedVouchersOverTime(startDate, endDate, sourceId, aimListFilter); return new VoucherGenerationRedemptionStatsResponse { TotalGenerated = generatedVouchers, @@ -291,13 +292,24 @@ public async Task FetchTotalVouchersGe public async Task<(int TotalCount, int RedeemedCount)> FetchTotalVouchersGeneratedAndRedeemed( DateTime? startDate, DateTime? endDate, - ObjectId? sourceId + ObjectId? sourceId, + string[] aimListFilter ) { var pipeline = new List(); pipeline.Add(new BsonDocument("$match", new BsonDocument("$and", new BsonArray(MongoQueryHelper.DateMatchCondition(startDate, endDate, "timestamp"))))); + + if (aimListFilter != null && aimListFilter.Any()) { + pipeline.Add( + new BsonDocument("$match", + new BsonDocument("aimCode", + new BsonDocument("$in", + new BsonArray(aimListFilter.Select(x => x.Trim()))))) + ); + } + pipeline.Add( new BsonDocument("$lookup", new BsonDocument { @@ -366,7 +378,8 @@ public async Task FetchTotalVouchersGe public async Task> FetchTotalVouchersGeneratedByAim( DateTime? startDate, DateTime? endDate, - ObjectId? sourceId + ObjectId? sourceId, + string[] aimListFilter ) { try { // Create the list to hold match conditions for the voucher collection @@ -382,6 +395,15 @@ public async Task> FetchTotalVouchersGeneratedByAim( pipeline.Add(new BsonDocument("$match", new BsonDocument("$and", new BsonArray(matchConditions)))); } + if (aimListFilter != null && aimListFilter.Any()) { + pipeline.Add( + new BsonDocument("$match", + new BsonDocument("aimCode", + new BsonDocument("$in", + new BsonArray(aimListFilter.Select(x => x.Trim()))))) + ); + } + if(sourceId.HasValue) { pipeline.Add( new BsonDocument("$lookup", @@ -541,7 +563,8 @@ public async Task FetchVouchersAvailable(double? latitude, double? longitud public async Task> GetTotalGeneratedRedeemedVouchersOverTime( DateTime? startDate, DateTime? endDate, - ObjectId? sourceId + ObjectId? sourceId, + string[] aimListFilter ) { var pipeline = new List(); @@ -567,6 +590,15 @@ public async Task> GetTotalGeneratedR })) ); + if (aimListFilter != null && aimListFilter.Any()) { + pipeline.Add( + new BsonDocument("$match", + new BsonDocument("aimCode", + new BsonDocument("$in", + new BsonArray(aimListFilter.Select(x => x.Trim()))))) + ); + } + pipeline.Add( new BsonDocument("$lookup", new BsonDocument { From b92fe30c44b485d8ab1009997137820ceb6af48e Mon Sep 17 00:00:00 2001 From: miris-mp Date: Wed, 11 Dec 2024 16:49:43 +0100 Subject: [PATCH 2/4] Adjusted 'Group by Day' threshold in GetDateFormatForRange --- src/ApiServer/ApiServer/Utilities/DateRangeHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ApiServer/ApiServer/Utilities/DateRangeHelper.cs b/src/ApiServer/ApiServer/Utilities/DateRangeHelper.cs index 00fb756..f2bf4f3 100644 --- a/src/ApiServer/ApiServer/Utilities/DateRangeHelper.cs +++ b/src/ApiServer/ApiServer/Utilities/DateRangeHelper.cs @@ -15,7 +15,7 @@ public static string GetDateFormatForRange(DateTime startDate, DateTime endDate) var totalDays = (endDate - startDate).TotalDays; - if (totalDays <= 7) + if (totalDays <= 30) { return "%Y-%m-%d"; // Group by day } From f49d8165e825dc6d8120d1bc4560b1935e33617e Mon Sep 17 00:00:00 2001 From: miris-mp Date: Mon, 16 Dec 2024 10:35:08 +0100 Subject: [PATCH 3/4] Added support for calculating and providing instrument rank --- .../ApiServer/Controllers/StatsController.cs | 2 + src/ApiServer/ApiServer/DTO/ElementRankDTO.cs | 20 ++ .../ApiServer/DTO/MerchantRankDTO.cs | 10 - ...oucherGenerationRedemptionStatsResponse.cs | 1 + .../ApiServer/Service/GenerationService.cs | 204 ++++++++++++++++-- .../ApiServer/Utilities/CsvFileHelper.cs | 1 + 6 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 src/ApiServer/ApiServer/DTO/ElementRankDTO.cs delete mode 100644 src/ApiServer/ApiServer/DTO/MerchantRankDTO.cs diff --git a/src/ApiServer/ApiServer/Controllers/StatsController.cs b/src/ApiServer/ApiServer/Controllers/StatsController.cs index fcc149e..8b462d5 100644 --- a/src/ApiServer/ApiServer/Controllers/StatsController.cs +++ b/src/ApiServer/ApiServer/Controllers/StatsController.cs @@ -82,6 +82,7 @@ [FromRoute] ObjectId sourceId }); } + // API to send back the data for generation and redeemed vouchers [HttpPost("vouchers/generated-redeemed-statistics")] [Authorize] [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] @@ -104,6 +105,7 @@ public async Task FetchVouchersGeneratedAndRedeemedStats([FromBody } } + // API to send back the data for consumed vouchers [HttpPost("vouchers/consumed-statistics")] [Authorize] [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] diff --git a/src/ApiServer/ApiServer/DTO/ElementRankDTO.cs b/src/ApiServer/ApiServer/DTO/ElementRankDTO.cs new file mode 100644 index 0000000..c4a8669 --- /dev/null +++ b/src/ApiServer/ApiServer/DTO/ElementRankDTO.cs @@ -0,0 +1,20 @@ +using MongoDB.Bson; + +namespace WomPlatform.Web.Api.DTO; + +public abstract class RankDTO { + public ObjectId Id { get; set; } + public string Name { get; set; } + public int Rank { get; set; } +} + +public class SourceRankDTO : RankDTO { + public int TotalGeneratedAmount { get; set; } + public int TotalRedeemedAmount { get; set; } + +} + +public class MerchantRankDTO : RankDTO { + public int Amount { get; set; } +} + diff --git a/src/ApiServer/ApiServer/DTO/MerchantRankDTO.cs b/src/ApiServer/ApiServer/DTO/MerchantRankDTO.cs deleted file mode 100644 index 2113ded..0000000 --- a/src/ApiServer/ApiServer/DTO/MerchantRankDTO.cs +++ /dev/null @@ -1,10 +0,0 @@ -using MongoDB.Bson; - -namespace WomPlatform.Web.Api.DTO; - -public class MerchantRankDTO { - public ObjectId Id { get; set; } - public string Name { get; set; } - public int Amount { get; set; } - public int Rank { get; set; } -} diff --git a/src/ApiServer/ApiServer/DTO/VoucherGenerationRedemptionStatsResponse.cs b/src/ApiServer/ApiServer/DTO/VoucherGenerationRedemptionStatsResponse.cs index a23ceac..3f60493 100644 --- a/src/ApiServer/ApiServer/DTO/VoucherGenerationRedemptionStatsResponse.cs +++ b/src/ApiServer/ApiServer/DTO/VoucherGenerationRedemptionStatsResponse.cs @@ -9,4 +9,5 @@ public class VoucherGenerationRedemptionStatsResponse { public List VoucherByAim { get; set; } public int VoucherAvailable { get; set; } public List TotalGeneratedAndRedeemedOverTime { get; set; } + public List SourceRank { get; set; } } diff --git a/src/ApiServer/ApiServer/Service/GenerationService.cs b/src/ApiServer/ApiServer/Service/GenerationService.cs index 19bc7a8..046ad32 100644 --- a/src/ApiServer/ApiServer/Service/GenerationService.cs +++ b/src/ApiServer/ApiServer/Service/GenerationService.cs @@ -274,15 +274,20 @@ public async Task FetchTotalVouchersGe double? longitude, int? radius ) { - var (generatedVouchers, redeemedVouchers) = await FetchTotalVouchersGeneratedAndRedeemed(startDate, endDate, sourceId, aimListFilter); - List voucherByAim = await FetchTotalVouchersGeneratedByAim(startDate, endDate, sourceId, aimListFilter); - List totalGeneratedRedeemedVouchersOverTime = await GetTotalGeneratedRedeemedVouchersOverTime(startDate, endDate, sourceId, aimListFilter); + var (generatedVouchers, redeemedVouchers) = + await FetchTotalVouchersGeneratedAndRedeemed(startDate, endDate, sourceId, aimListFilter); + List voucherByAim = + await FetchTotalVouchersGeneratedByAim(startDate, endDate, sourceId, aimListFilter); + List totalGeneratedRedeemedVouchersOverTime = + await GetTotalGeneratedRedeemedVouchersOverTime(startDate, endDate, sourceId, aimListFilter); + List sourceRank = await GetSourceRank(startDate, endDate, sourceId, aimListFilter); return new VoucherGenerationRedemptionStatsResponse { TotalGenerated = generatedVouchers, TotalRedeemed = redeemedVouchers, VoucherByAim = voucherByAim, - TotalGeneratedAndRedeemedOverTime = totalGeneratedRedeemedVouchersOverTime + TotalGeneratedAndRedeemedOverTime = totalGeneratedRedeemedVouchersOverTime, + SourceRank = sourceRank, }; } @@ -301,7 +306,7 @@ string[] aimListFilter new BsonDocument("$and", new BsonArray(MongoQueryHelper.DateMatchCondition(startDate, endDate, "timestamp"))))); - if (aimListFilter != null && aimListFilter.Any()) { + if(aimListFilter != null && aimListFilter.Any()) { pipeline.Add( new BsonDocument("$match", new BsonDocument("aimCode", @@ -395,7 +400,7 @@ string[] aimListFilter pipeline.Add(new BsonDocument("$match", new BsonDocument("$and", new BsonArray(matchConditions)))); } - if (aimListFilter != null && aimListFilter.Any()) { + if(aimListFilter != null && aimListFilter.Any()) { pipeline.Add( new BsonDocument("$match", new BsonDocument("aimCode", @@ -494,44 +499,40 @@ public async Task FetchVouchersAvailable(double? latitude, double? longitud ); pipeline.Add( new BsonDocument("$lookup", - new BsonDocument - { + new BsonDocument { { "from", "GenerationRequests" }, { "localField", "generationRequestId" }, { "foreignField", "_id" }, { "as", "generationRequest" } }) - ); + ); pipeline.Add( new BsonDocument("$unwind", - new BsonDocument - { + new BsonDocument { { "path", "$generationRequest" }, { "includeArrayIndex", "string" }, { "preserveNullAndEmptyArrays", true } }) - ); + ); pipeline.Add( new BsonDocument("$match", new BsonDocument("generationRequest.performedAt", - new BsonDocument - { + new BsonDocument { { "$exists", true }, { "$ne", BsonNull.Value } })) - ); + ); pipeline.Add( new BsonDocument("$project", - new BsonDocument - { + new BsonDocument { { "source", 1 }, { "initialCount", 1 }, { "count", 1 }, { "generationRequest.performedAt", 1 } }) - ); + ); pipeline.Add( new BsonDocument("$group", new BsonDocument { @@ -569,10 +570,11 @@ string[] aimListFilter var pipeline = new List(); // set calculation on last year if period of time is not specified - if (!startDate.HasValue && !endDate.HasValue) { + if(!startDate.HasValue && !endDate.HasValue) { endDate = DateTime.Today; // Set to today startDate = DateTime.Today.AddYears(-1); // One year ago } + var formatDate = DateRangeHelper.GetDateFormatForRange(startDate.Value, endDate.Value); startDate = startDate.Value.Date; // Truncate to midnight @@ -590,7 +592,7 @@ string[] aimListFilter })) ); - if (aimListFilter != null && aimListFilter.Any()) { + if(aimListFilter != null && aimListFilter.Any()) { pipeline.Add( new BsonDocument("$match", new BsonDocument("aimCode", @@ -670,11 +672,10 @@ string[] aimListFilter // Get the list of all dates between startDate and endDate var allDates = new List(); - var currentDate = startDate.Value.Date; // Start with the initial date + var currentDate = startDate.Value.Date; // Start with the initial date // While currentDate is less than or equal to endDate - while (currentDate <= endDate.Value.Date) - { + while(currentDate <= endDate.Value.Date) { allDates.Add(currentDate.ToString(netFormatDate)); // Increment the date using the appropriate logic based on netFormatDate @@ -707,5 +708,162 @@ string[] aimListFilter return vouchersByAim; } + + public async Task> GetSourceRank(DateTime? startDate, DateTime? endDate, + ObjectId? sourceId, string[] aimListFilter) { + var pipeline = new List(); + + // Create the list to hold match conditions for the voucher collection + List matchConditions = + MongoQueryHelper.DateMatchCondition(startDate, endDate, "timestamp"); + + + // Add the date match conditions + if(matchConditions.Count > 0) { + pipeline.Add(new BsonDocument("$match", new BsonDocument("$and", new BsonArray(matchConditions)))); + } + + if(aimListFilter != null && aimListFilter.Any()) { + pipeline.Add( + new BsonDocument("$match", + new BsonDocument("aimCode", + new BsonDocument("$in", + new BsonArray(aimListFilter.Select(x => x.Trim()))))) + ); + } + + pipeline.Add(new BsonDocument("$sort", + new BsonDocument("totalRedeemedAmount", -1))); + + // If instrumentName is provided, add the lookup and match conditions + if(sourceId.HasValue) { + var sourceMatchConditions = MongoQueryHelper.SourceMatchFromVouchersCondition(sourceId); + pipeline.AddRange(sourceMatchConditions); + } + + + + // $lookup: GenerationRequests + pipeline.Add( + new BsonDocument("$lookup", + new BsonDocument + { + { "from", "GenerationRequests" }, + { "localField", "generationRequestId" }, + { "foreignField", "_id" }, + { "as", "gen" } + } + ) + ); + + // $unwind: gen + pipeline.Add( + new BsonDocument("$unwind", + new BsonDocument + { + { "path", "$gen" }, + { "includeArrayIndex", "string" }, + { "preserveNullAndEmptyArrays", false } + } + ) + ); + + // $lookup: Sources + pipeline.Add( + new BsonDocument("$lookup", + new BsonDocument + { + { "from", "Sources" }, + { "localField", "gen.sourceId" }, + { "foreignField", "_id" }, + { "as", "source" } + } + ) + ); + + // $unwind: source + pipeline.Add( + new BsonDocument("$unwind", + new BsonDocument + { + { "path", "$source" }, + { "includeArrayIndex", "string" }, + { "preserveNullAndEmptyArrays", false } + } + ) + ); + + // $group: Aggregate totals + pipeline.Add( + new BsonDocument("$group", + new BsonDocument + { + { "_id", "$source._id" }, + { "name", new BsonDocument("$first", "$source.name") }, + { "totalGeneratedAmount", new BsonDocument("$sum", "$initialCount") }, + { "totalRedeemedAmount", + new BsonDocument("$sum", + new BsonDocument("$cond", + new BsonDocument + { + { "if", + new BsonDocument("$gt", + new BsonArray + { + "$gen.performedAt", + BsonNull.Value + } + ) + }, + { "then", "$initialCount" }, + { "else", 0 } + } + ) + ) + } + } + ) + ); + + // $sort: totalRedeemedAmount descending + pipeline.Add( + new BsonDocument("$setWindowFields", + new BsonDocument { + { + "sortBy", + new BsonDocument("totalGeneratedAmount", -1) + }, { + "output", + new BsonDocument("rank", + new BsonDocument("$denseRank", + new BsonDocument())) + } + })); + + try { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await VoucherCollection.AggregateAsync(pipeline); + var sourceRankList = await result.ToListAsync(); + stopwatch.Stop(); + var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + Logger.LogInformation($"Rank Aggregation pipeline executed in {elapsedMilliseconds} ms"); + + // Map to a strongly-typed model + var sourceRank = sourceRankList.Select(doc => new SourceRankDTO() { + Id = doc["_id"].IsBsonNull ? ObjectId.Empty : doc["_id"].AsObjectId, + Name = doc["name"].AsString, + TotalGeneratedAmount = doc["totalGeneratedAmount"].AsInt32, + TotalRedeemedAmount = doc["totalRedeemedAmount"].AsInt32, + Rank = doc["rank"].AsInt32 + }).ToList(); + + return sourceRank; + } + catch(Exception ex) { + Logger.LogError($"An error occurred: {ex.Message}"); + throw; + } + } } } diff --git a/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs b/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs index ec9acfd..f539820 100644 --- a/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs +++ b/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs @@ -23,6 +23,7 @@ public static byte[] GenerateCsvContent(VoucherGenerationRedemptionStatsResponse csvData.AddRange(consumedResponse.MerchantRanks.Select(item => new { Category = $"Merchant Rank {item.Rank} ({item.Name})", Value = item.Amount })); csvData.AddRange(consumedResponse.VoucherByAims.Select(item => new { Category = $"Voucher Consumed ({item.AimCode})", Value = item.Amount })); csvData.AddRange(genRedResponse.VoucherByAim.Select(item => new { Category = $"Voucher Generated ({item.AimCode})", Value = item.Amount })); + csvData.AddRange(genRedResponse.SourceRank.Select(item => new { Category = $"Source Rank {item.Rank} ({item.Name})", Value = item.TotalGeneratedAmount, TotalRedeemedAmount = item.TotalRedeemedAmount })); using (var memoryStream = new MemoryStream()) using (var writer = new StreamWriter(memoryStream, leaveOpen: true)) From ca7b1255f9d1e4192bbe3e3979e91d1d7d549804 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 10 Jan 2025 11:17:29 +0100 Subject: [PATCH 4/4] Refactor Statistics: Improve CSV Structure, Add Missing Data, and Update Rank Pipeline --- .../ApiServer/Controllers/StatsController.cs | 774 +++++++++--------- src/ApiServer/ApiServer/DTO/FiltersDTO.cs | 13 + .../ApiServer/Service/GenerationService.cs | 38 + .../ApiServer/Service/PaymentService.cs | 8 +- .../ApiServer/Utilities/CsvFileHelper.cs | 91 +- 5 files changed, 515 insertions(+), 409 deletions(-) create mode 100644 src/ApiServer/ApiServer/DTO/FiltersDTO.cs diff --git a/src/ApiServer/ApiServer/Controllers/StatsController.cs b/src/ApiServer/ApiServer/Controllers/StatsController.cs index 8b462d5..45ec2c7 100644 --- a/src/ApiServer/ApiServer/Controllers/StatsController.cs +++ b/src/ApiServer/ApiServer/Controllers/StatsController.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Net.Mime; using System.Threading.Tasks; @@ -12,431 +11,440 @@ using WomPlatform.Web.Api.OutputModels.Stats; using WomPlatform.Web.Api.Utilities; -namespace WomPlatform.Web.Api.Controllers { - [Route("v1/stats")] - [OperationsTags("Stats and info")] - [RequireHttpsInProd] - public class StatsController : BaseRegistryController { - public StatsController( - IServiceProvider serviceProvider, - ILogger logger) - : base(serviceProvider, logger) { - } +namespace WomPlatform.Web.Api.Controllers; - /// - /// Provides a count of all existing vouchers. - /// - [HttpGet("vouchers")] - [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(typeof(VouchersGeneralStatsResponse), StatusCodes.Status200OK)] - public async Task GetVoucherStats() { - var results = await StatsService.GetVoucherCountByAim(); - - var totalGenerated = results.Sum(a => a.TotalCount); - var totalAvailable = results.Sum(a => a.AvailableCount); - - return Ok(new VouchersGeneralStatsResponse { - TotalVouchersGenerated = totalGenerated, - TotalVouchersRedeemed = results.Sum(a => a.RedeemedCount), - TotalVouchersAvailable = totalAvailable, - TotalVouchersSpent = totalGenerated - totalAvailable, - Aims = results.ToDictionary( - a => a.AimCode, - a => new VouchersGeneralStatsResponse.VouchersByAimStatsResponse { - Generated = a.TotalCount, - Redeemed = a.RedeemedCount, - Available = a.AvailableCount, - Spent = a.TotalCount - a.AvailableCount - } - ) - }); - } +[Route("v1/stats")] +[OperationsTags("Stats and info")] +[RequireHttpsInProd] +public class StatsController : BaseRegistryController { + public StatsController( + IServiceProvider serviceProvider, + ILogger logger) + : base(serviceProvider, logger) { + } - /// - /// Provides a count of vouchers produced by a given source. - /// Request must be authorized by a user who is an administrator of the source. - /// - [HttpGet("vouchers/{sourceId}")] - [Authorize] - [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(typeof(VoucherSourceStatsResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task GetSourceVoucherStats( - [FromRoute] ObjectId sourceId - ) { - var source = await SourceService.GetSourceById(sourceId); - if(source == null) { - return NotFound(); - } - - if(!User.GetUserId(out var loggedUserId) || !source.AdministratorUserIds.Contains(loggedUserId)) { - return Forbid(); - } - - var result = await StatsService.GetVoucherCountBySource(sourceId); - - return Ok(new VoucherSourceStatsResponse { - GenerationRequests = result?.GenerationRequests ?? 0, - TotalVouchersGenerated = result?.TotalCount ?? 0, - TotalVouchersRedeemed = result?.RedeemedCount ?? 0 - }); - } + /// + /// Provides a count of all existing vouchers. + /// + [HttpGet("vouchers")] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(VouchersGeneralStatsResponse), StatusCodes.Status200OK)] + public async Task GetVoucherStats() { + var results = await StatsService.GetVoucherCountByAim(); + + var totalGenerated = results.Sum(a => a.TotalCount); + var totalAvailable = results.Sum(a => a.AvailableCount); + + return Ok(new VouchersGeneralStatsResponse { + TotalVouchersGenerated = totalGenerated, + TotalVouchersRedeemed = results.Sum(a => a.RedeemedCount), + TotalVouchersAvailable = totalAvailable, + TotalVouchersSpent = totalGenerated - totalAvailable, + Aims = results.ToDictionary( + a => a.AimCode, + a => new VouchersGeneralStatsResponse.VouchersByAimStatsResponse { + Generated = a.TotalCount, + Redeemed = a.RedeemedCount, + Available = a.AvailableCount, + Spent = a.TotalCount - a.AvailableCount + } + ) + }); + } - // API to send back the data for generation and redeemed vouchers - [HttpPost("vouchers/generated-redeemed-statistics")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchVouchersGeneratedAndRedeemedStats([FromBody] StatisticsRequestDto request) { - try { - // check if user is admin or owner of the source - await IsUserAdminOrOwnerSource(request.SourceId); - - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - - // Call separate methods or services to retrieve stats - var response = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter, request.Latitude, request.Longitude, request.Radius); - - return Ok(response); - } - catch(ServiceProblemException e) { - return StatusCode(e.HttpStatus, e.Message); - } + /// + /// Provides a count of vouchers produced by a given source. + /// Request must be authorized by a user who is an administrator of the source. + /// + [HttpGet("vouchers/{sourceId}")] + [Authorize] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(VoucherSourceStatsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task GetSourceVoucherStats( + [FromRoute] ObjectId sourceId + ) { + var source = await SourceService.GetSourceById(sourceId); + if(source == null) { + return NotFound(); } - // API to send back the data for consumed vouchers - [HttpPost("vouchers/consumed-statistics")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchVouchersConsumedStats([FromBody] StatisticsRequestDto request) { - try { - // check if user is admin or owner of the source - await IsUserAdminOrOwnerMerchant(request.MerchantId); - - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - - // Fetch the total amount of consumed vouchers - var consumedVouchers = await PaymentService.FetchTotalVouchersConsumedStats(parsedStartDate, parsedEndDate, request.MerchantId); - - // Return the JSON response - return Ok(consumedVouchers); - } - catch(ServiceProblemException ex) { - return StatusCode(ex.HttpStatus, ex.Message); - } + if(!User.GetUserId(out var loggedUserId) || !source.AdministratorUserIds.Contains(loggedUserId)) { + return Forbid(); } + var result = await StatsService.GetVoucherCountBySource(sourceId); - /// - /// Retrieves the total number of vouchers generated and redeemed within the specified date range and source. - /// This is a POST request where filters are passed in the request body. - /// - /// - /// - This endpoint is restricted to admin users or authorized users of the source. - /// - Date range and source ID can be provided via the request body. - /// - /// Object containing startDate, endDate, and sourceId filters. - /// A JSON object containing total vouchers generated and redeemed. - [HttpPost("vouchers/total-generated-redeemed")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchTotalVouchersGeneratedAndRedeemed( - [FromBody] StatisticsRequestDto request - ) { - try { - // check if user is admin or owner of the source - await IsUserAdminOrOwnerSource(request.SourceId); - - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - - // Fetch the total amount of generated and redeemed vouchers, passing the optional date range - var generatedVouchers = - await GenerationService.FetchTotalVouchersGeneratedAndRedeemed(parsedStartDate, parsedEndDate,request.SourceId, request.AimListFilter); - - - // Return the JSON response - return Ok(new { TotalCount = generatedVouchers.TotalCount, RedeemedCount = generatedVouchers.RedeemedCount }); - } - catch(ServiceProblemException ex) { - return StatusCode(ex.HttpStatus, ex.Message); - } - } + return Ok(new VoucherSourceStatsResponse { + GenerationRequests = result?.GenerationRequests ?? 0, + TotalVouchersGenerated = result?.TotalCount ?? 0, + TotalVouchersRedeemed = result?.RedeemedCount ?? 0 + }); + } - /// - /// Gets the total amount of consumed vouchers within the specified date range. - /// - /// - /// This endpoint is restricted to admin users. If the user is not an admin, a 403 Forbidden status is returned. - /// The date range can be modified to take parameters from the query string. - /// - /// - /// Returns a 200 OK status with the total number of consumed vouchers. - /// If the user is not authorized, a 403 Forbidden status is returned. - /// - [HttpPost("vouchers/total-consumed")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchTotalVouchersConsumed( - [FromBody] StatisticsRequestDto request - ) { - try { - // check if user is admin or owner of the source - await IsUserAdminOrOwnerMerchant(request.MerchantId); - - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - - // Fetch the total amount of consumed vouchers - var consumedVouchers = - await PaymentService.FetchTotalVouchersConsumed(parsedStartDate, parsedEndDate, request.MerchantId); - - // Return the JSON response - return Ok(consumedVouchers); - } - catch(ServiceProblemException ex) { - return StatusCode(ex.HttpStatus, ex.Message); - } - } + // API to send back the data for generation and redeemed vouchers + [HttpPost("vouchers/generated-redeemed-statistics")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchVouchersGeneratedAndRedeemedStats([FromBody] StatisticsRequestDto request) { + try { + // check if user is admin or owner of the source + await IsUserAdminOrOwnerSource(request.SourceId); - /// - /// Retrieves the total number of vouchers generated, grouped by aim, within an optional date range or for a specific source. - /// - /// - /// - Restricted to admin users or owners of the source. - /// - If the user is not authorized, a 403 Forbidden status is returned. - /// - Optional filters include a date range (startDate, endDate) and source ID. - /// - If no filters are provided, the statistics are calculated for the entire available dataset. - /// - /// An object containing optional filters: startDate, endDate, and sourceId. - /// A JSON response containing the total vouchers generated, grouped by aim. - /// Returns the statistics of generated vouchers grouped by aim. - /// If the user is not authorized to access the source data. - /// If the source is not found. - - [HttpPost("vouchers/total-generated-by-aim")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchTotalVouchersGeneratedByAim( - [FromBody] StatisticsRequestDto request - ) { - try { - // check if user is admin or owner of the source - await IsUserAdminOrOwnerSource(request.SourceId); - - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - - var generatedVouchersByAim = - await GenerationService.FetchTotalVouchersGeneratedByAim(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter); - return Ok(generatedVouchersByAim); - } - catch(ServiceProblemException e) { - return StatusCode(e.HttpStatus, e.Message); - } - } + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - /// - /// Gets the list of consumed vouchers grouped by aim - /// - /// - /// This endpoint is restricted to admin users. If the user is not an admin, a 403 Forbidden status is returned. - /// The date range can be modified to take parameters from the query string. - /// - /// - /// Returns a 200 OK status with the list. - /// If the user is not authorized, a 403 Forbidden status is returned. - /// - [HttpPost("vouchers/total-consumed-by-aims")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchTotalVouchersConsumedByAim( - [FromBody] StatisticsRequestDto request - ) { - try { - // check if user is admin or owner of the source - await IsUserAdminOrOwnerMerchant(request.MerchantId); - - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - - // Fetch the list of consumed vouchers based on aim - var listConsumedByAims = - await PaymentService.FetchTotalVouchersConsumedByAim(parsedStartDate, parsedEndDate, request.MerchantId); - - // Return consumed vouchers divided for period - return Ok(listConsumedByAims); - } - catch(ServiceProblemException e) { - return StatusCode(e.HttpStatus, e.Message); - } + // Call separate methods or services to retrieve stats + var response = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter, request.Latitude, request.Longitude, request.Radius); + + return Ok(response); + } + catch(ServiceProblemException e) { + return StatusCode(e.HttpStatus, e.Message); } + } - /// - /// Retrieves a list of consumed vouchers grouped by offers for a merchant. - /// This is a POST request where filters are passed in the request body. - /// - /// - /// - This endpoint is restricted to merchant users. - /// - The request should contain an optional merchantId for which the vouchers were consumed. - /// - /// Object containing the merchantId filter. - /// A list of consumed vouchers grouped by offer. - [HttpPost("merchant/voucher/total-consumed-by-offer")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchConsumedVouchersByOffer( - [FromBody] StatisticsRequestDto request - ) { + // API to send back the data for consumed vouchers + [HttpPost("vouchers/consumed-statistics")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchVouchersConsumedStats([FromBody] StatisticsRequestDto request) { + try { + // check if user is admin or owner of the source await IsUserAdminOrOwnerMerchant(request.MerchantId); - // ******************************** - // TO ADD DATA FILTER ON SERVICE - // **************************** - if(request.MerchantId.HasValue) { - // Fetch the list of consumed vouchers based on the merchant offer - var listConsumedByOffer = await OfferService.FetchConsumedVouchersByOffer(request.MerchantId.Value); - - // Return consumed vouchers divided for period - return Ok(listConsumedByOffer); - } - - return (BadRequest()); + + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); + + // Fetch the total amount of consumed vouchers + var consumedVouchers = await PaymentService.FetchTotalVouchersConsumedStats(parsedStartDate, parsedEndDate, request.MerchantId); + + // Return the JSON response + return Ok(consumedVouchers); } + catch(ServiceProblemException ex) { + return StatusCode(ex.HttpStatus, ex.Message); + } + } + + + /// + /// Retrieves the total number of vouchers generated and redeemed within the specified date range and source. + /// This is a POST request where filters are passed in the request body. + /// + /// + /// - This endpoint is restricted to admin users or authorized users of the source. + /// - Date range and source ID can be provided via the request body. + /// + /// Object containing startDate, endDate, and sourceId filters. + /// A JSON object containing total vouchers generated and redeemed. + [HttpPost("vouchers/total-generated-redeemed")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchTotalVouchersGeneratedAndRedeemed( + [FromBody] StatisticsRequestDto request + ) { + try { + // check if user is admin or owner of the source + await IsUserAdminOrOwnerSource(request.SourceId); + + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - /// - /// Retrieves the rank of merchants based on the total amount of vouchers consumed in a specified period. - /// This is a POST request where filters are passed in the request body. - /// - /// - /// - Restricted to merchant users. - /// - The request should contain optional filters for date range. - /// - /// Object containing startDate and endDate filters for ranking. - /// Merchants rank based on consumed vouchers. - [HttpPost("merchant/rank-consumed")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchMerchantsRank( - [FromBody] StatisticsRequestDto request - ) { - try { - // check if user is admin or owner of the source - await IsUserAdminOrOwnerMerchant(request.MerchantId); - - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - - // Fetch the list of consumed vouchers based on aim - var merchantRank = await PaymentService.GetMerchantRank(parsedStartDate, parsedEndDate, request.MerchantId); - - // Return consumed vouchers divided for period - return Ok(merchantRank); - } - catch(ServiceProblemException e) { - return StatusCode(e.HttpStatus, e.Message); - } + // Fetch the total amount of generated and redeemed vouchers, passing the optional date range + var generatedVouchers = + await GenerationService.FetchTotalVouchersGeneratedAndRedeemed(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter); + + + // Return the JSON response + return Ok(new { generatedVouchers.TotalCount, generatedVouchers.RedeemedCount }); } + catch(ServiceProblemException ex) { + return StatusCode(ex.HttpStatus, ex.Message); + } + } - /// - /// Get the total number of unused vouchers by position - /// - [HttpPost("voucher/available")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task FetchVouchersAvailable( - [FromBody] StatisticsRequestDto request - ) { - // Think how to make a control on this api + /// + /// Gets the total amount of consumed vouchers within the specified date range. + /// + /// + /// This endpoint is restricted to admin users. If the user is not an admin, a 403 Forbidden status is returned. + /// The date range can be modified to take parameters from the query string. + /// + /// + /// Returns a 200 OK status with the total number of consumed vouchers. + /// If the user is not authorized, a 403 Forbidden status is returned. + /// + [HttpPost("vouchers/total-consumed")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchTotalVouchersConsumed( + [FromBody] StatisticsRequestDto request + ) { + try { + // check if user is admin or owner of the source await IsUserAdminOrOwnerMerchant(request.MerchantId); - // Fetch the number of unused vouchers - var numberUnusedVouchers = await GenerationService.FetchVouchersAvailable(request.Latitude, request.Longitude, request.Radius); + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); + + // Fetch the total amount of consumed vouchers + var consumedVouchers = + await PaymentService.FetchTotalVouchersConsumed(parsedStartDate, parsedEndDate, request.MerchantId); - // Return consumed vouchers divided for period - return Ok(numberUnusedVouchers); + // Return the JSON response + return Ok(consumedVouchers); } + catch(ServiceProblemException ex) { + return StatusCode(ex.HttpStatus, ex.Message); + } + } - /// - /// Retrieves the total number of vouchers generated over time based on optional filters such as date range and source. - /// - /// The start date for the date range filter (optional). - /// The end date for the date range filter (optional). - /// The ID of the voucher source to filter by (optional). - /// The total number of vouchers generated within the specified criteria. - [HttpPost("voucher/total-generated-redeemed-over-time")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task GetTotalGeneratedRedeemedVouchersOverTime( - [FromBody] StatisticsRequestDto request - ) { + /// + /// Retrieves the total number of vouchers generated, grouped by aim, within an optional date range or for a specific + /// source. + /// + /// + /// - Restricted to admin users or owners of the source. + /// - If the user is not authorized, a 403 Forbidden status is returned. + /// - Optional filters include a date range (startDate, endDate) and source ID. + /// - If no filters are provided, the statistics are calculated for the entire available dataset. + /// + /// An object containing optional filters: startDate, endDate, and sourceId. + /// A JSON response containing the total vouchers generated, grouped by aim. + /// Returns the statistics of generated vouchers grouped by aim. + /// If the user is not authorized to access the source data. + /// If the source is not found. + [HttpPost("vouchers/total-generated-by-aim")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchTotalVouchersGeneratedByAim( + [FromBody] StatisticsRequestDto request + ) { + try { // check if user is admin or owner of the source await IsUserAdminOrOwnerSource(request.SourceId); var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - // Fetch the list of consumed vouchers based on aim - var totalGeneratedVouchersOverTime = await GenerationService.GetTotalGeneratedRedeemedVouchersOverTime(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter); - return Ok(totalGeneratedVouchersOverTime); + var generatedVouchersByAim = + await GenerationService.FetchTotalVouchersGeneratedByAim(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter); + return Ok(generatedVouchersByAim); + } + catch(ServiceProblemException e) { + return StatusCode(e.HttpStatus, e.Message); } + } - /// - /// Retrieves the total number of voucher usage over time based on optional filters like period of time and merchant. - /// - /// The start date to filter by (optional). - /// The end date to filter by (optional). - /// The ID of the merchant to filter by (optional). - /// The total number of voucher consumed over time. - [HttpPost("voucher/total-consumption-over-time")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task GetTotalConsumptionVouchersOverTime( - [FromBody] StatisticsRequestDto request - ) { + /// + /// Gets the list of consumed vouchers grouped by aim + /// + /// + /// This endpoint is restricted to admin users. If the user is not an admin, a 403 Forbidden status is returned. + /// The date range can be modified to take parameters from the query string. + /// + /// + /// Returns a 200 OK status with the list. + /// If the user is not authorized, a 403 Forbidden status is returned. + /// + [HttpPost("vouchers/total-consumed-by-aims")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchTotalVouchersConsumedByAim( + [FromBody] StatisticsRequestDto request + ) { + try { // check if user is admin or owner of the source await IsUserAdminOrOwnerMerchant(request.MerchantId); var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); // Fetch the list of consumed vouchers based on aim - var totalConsumedVouchersOverTime = await PaymentService.GetTotalConsumedVouchersOverTime(parsedStartDate, parsedEndDate, request.MerchantId); + var listConsumedByAims = + await PaymentService.FetchTotalVouchersConsumedByAim(parsedStartDate, parsedEndDate, request.MerchantId); - return Ok(totalConsumedVouchersOverTime); + // Return consumed vouchers divided for period + return Ok(listConsumedByAims); } + catch(ServiceProblemException e) { + return StatusCode(e.HttpStatus, e.Message); + } + } - [HttpPost("download/csv")] - [Authorize] - [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - public async Task DownloadCsv([FromBody] StatisticsRequestDto request) { - await VerifyUserIsAdmin(); // check if user is an admin + /// + /// Retrieves a list of consumed vouchers grouped by offers for a merchant. + /// This is a POST request where filters are passed in the request body. + /// + /// + /// - This endpoint is restricted to merchant users. + /// - The request should contain an optional merchantId for which the vouchers were consumed. + /// + /// Object containing the merchantId filter. + /// A list of consumed vouchers grouped by offer. + [HttpPost("merchant/voucher/total-consumed-by-offer")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchConsumedVouchersByOffer( + [FromBody] StatisticsRequestDto request + ) { + await IsUserAdminOrOwnerMerchant(request.MerchantId); + // ******************************** + // TO ADD DATA FILTER ON SERVICE + // **************************** + if(request.MerchantId.HasValue) { + // Fetch the list of consumed vouchers based on the merchant offer + var listConsumedByOffer = await OfferService.FetchConsumedVouchersByOffer(request.MerchantId.Value); - // if dates present check dates are valid and in case parse them - var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); + // Return consumed vouchers divided for period + return Ok(listConsumedByOffer); + } + + return BadRequest(); + } + + /// + /// Retrieves the rank of merchants based on the total amount of vouchers consumed in a specified period. + /// This is a POST request where filters are passed in the request body. + /// + /// + /// - Restricted to merchant users. + /// - The request should contain optional filters for date range. + /// + /// Object containing startDate and endDate filters for ranking. + /// Merchants rank based on consumed vouchers. + [HttpPost("merchant/rank-consumed")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchMerchantsRank( + [FromBody] StatisticsRequestDto request + ) { + try { + // check if user is admin or owner of the source + await IsUserAdminOrOwnerMerchant(request.MerchantId); - // create general API to call them and save the data - var genRedResponse = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter, request.Latitude, request.Longitude, request.Radius); - var consumedResponse = await PaymentService.FetchTotalVouchersConsumedStats(parsedStartDate, parsedEndDate, request.MerchantId); + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); - var records = CsvFileHelper.GenerateCsvContent(genRedResponse, consumedResponse); + // Fetch the list of consumed vouchers based on aim + var merchantRank = await PaymentService.GetMerchantRank(parsedStartDate, parsedEndDate, request.MerchantId); - return File(records, "text/csv", $"{DateTime.Now:yyyy-M-d dddd}_stats.csv"); + // Return consumed vouchers divided for period + return Ok(merchantRank); + } + catch(ServiceProblemException e) { + return StatusCode(e.HttpStatus, e.Message); } } + + /// + /// Get the total number of unused vouchers by position + /// + [HttpPost("voucher/available")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task FetchVouchersAvailable( + [FromBody] StatisticsRequestDto request + ) { + // Think how to make a control on this api + await IsUserAdminOrOwnerMerchant(request.MerchantId); + + // Fetch the number of unused vouchers + var numberUnusedVouchers = await GenerationService.FetchVouchersAvailable(request.Latitude, request.Longitude, request.Radius); + + // Return consumed vouchers divided for period + return Ok(numberUnusedVouchers); + } + + /// + /// Retrieves the total number of vouchers generated over time based on optional filters such as date range and source. + /// + /// The start date for the date range filter (optional). + /// The end date for the date range filter (optional). + /// The ID of the voucher source to filter by (optional). + /// The total number of vouchers generated within the specified criteria. + [HttpPost("voucher/total-generated-redeemed-over-time")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task GetTotalGeneratedRedeemedVouchersOverTime( + [FromBody] StatisticsRequestDto request + ) { + // check if user is admin or owner of the source + await IsUserAdminOrOwnerSource(request.SourceId); + + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); + + // Fetch the list of consumed vouchers based on aim + var totalGeneratedVouchersOverTime = await GenerationService.GetTotalGeneratedRedeemedVouchersOverTime(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter); + return Ok(totalGeneratedVouchersOverTime); + } + + /// + /// Retrieves the total number of voucher usage over time based on optional filters like period of time and merchant. + /// + /// The start date to filter by (optional). + /// The end date to filter by (optional). + /// The ID of the merchant to filter by (optional). + /// The total number of voucher consumed over time. + [HttpPost("voucher/total-consumption-over-time")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task GetTotalConsumptionVouchersOverTime( + [FromBody] StatisticsRequestDto request + ) { + // check if user is admin or owner of the source + await IsUserAdminOrOwnerMerchant(request.MerchantId); + + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); + + // Fetch the list of consumed vouchers based on aim + var totalConsumedVouchersOverTime = await PaymentService.GetTotalConsumedVouchersOverTime(parsedStartDate, parsedEndDate, request.MerchantId); + + return Ok(totalConsumedVouchersOverTime); + } + + [HttpPost("download/csv")] + [Authorize] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + public async Task DownloadCsv([FromBody] StatisticsRequestDto request) { + await VerifyUserIsAdmin(); // check if user is an admin + + // if dates present check dates are valid and in case parse them + var (parsedStartDate, parsedEndDate) = DateRangeHelper.ParseAndValidateDates(request.StartDate, request.EndDate); + + // create general API to call them and save the data + var genRedResponse = await GenerationService.FetchTotalVouchersGeneratedAndRedeemedStats(parsedStartDate, parsedEndDate, request.SourceId, request.AimListFilter, request.Latitude, request.Longitude, request.Radius); + var consumedResponse = await PaymentService.FetchTotalVouchersConsumedStats(parsedStartDate, parsedEndDate, request.MerchantId); + var availableResponse = await GenerationService.FetchVouchersAvailable(request.Latitude, request.Longitude, request.Radius); + + var filters = new FiltersDTO { + StartDate = parsedStartDate, + EndDate = parsedEndDate, + SourceId = request.SourceId, + MerchantId = request.MerchantId, + AimFilter = request.AimListFilter + }; + + var records = CsvFileHelper.GenerateCsvContent(genRedResponse, consumedResponse, availableResponse, filters); + + return File(records, "text/csv", $"{DateTime.Now:yyyy-M-d dddd}_stats.csv"); + } } diff --git a/src/ApiServer/ApiServer/DTO/FiltersDTO.cs b/src/ApiServer/ApiServer/DTO/FiltersDTO.cs new file mode 100644 index 0000000..2e84280 --- /dev/null +++ b/src/ApiServer/ApiServer/DTO/FiltersDTO.cs @@ -0,0 +1,13 @@ +using System; +using MongoDB.Bson; + +namespace WomPlatform.Web.Api.DTO; + +public class FiltersDTO { + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public ObjectId? SourceId { get; set; } + public ObjectId? MerchantId { get; set; } + public string[]? AimFilter { get; set; } +} + diff --git a/src/ApiServer/ApiServer/Service/GenerationService.cs b/src/ApiServer/ApiServer/Service/GenerationService.cs index 046ad32..b4c42e0 100644 --- a/src/ApiServer/ApiServer/Service/GenerationService.cs +++ b/src/ApiServer/ApiServer/Service/GenerationService.cs @@ -825,6 +825,44 @@ public async Task> GetSourceRank(DateTime? startDate, DateTi ) ); + pipeline.Add( + new BsonDocument( + "$unionWith", + new BsonDocument + { + { "coll", "Sources" }, + { "pipeline", + new BsonArray + { + new BsonDocument("$project", + new BsonDocument + { + { "_id", "$_id" }, + { "name", "$name" }, + { "totalGeneratedAmount", + new BsonDocument("$literal", 0) }, + { "totalRedeemedAmount", + new BsonDocument("$literal", 0) } + }) + } } + } + ) + ); + + pipeline.Add( + new BsonDocument("$group", + new BsonDocument + { + { "_id", "$_id" }, + { "name", + new BsonDocument("$first", "$name") }, + { "totalGeneratedAmount", + new BsonDocument("$max", "$totalGeneratedAmount") }, + { "totalRedeemedAmount", + new BsonDocument("$max", "$totalRedeemedAmount") } + }) + ); + // $sort: totalRedeemedAmount descending pipeline.Add( new BsonDocument("$setWindowFields", diff --git a/src/ApiServer/ApiServer/Service/PaymentService.cs b/src/ApiServer/ApiServer/Service/PaymentService.cs index dfec301..fbdcfd1 100644 --- a/src/ApiServer/ApiServer/Service/PaymentService.cs +++ b/src/ApiServer/ApiServer/Service/PaymentService.cs @@ -644,10 +644,10 @@ public async Task> GetMerchantRank( new BsonDocument("name", new BsonDocument("$ne", BsonNull.Value)))); - pipeline.Add( - new BsonDocument("$match", - new BsonDocument("totalAmount", - new BsonDocument("$gt", 0)))); + // pipeline.Add( + // new BsonDocument("$match", + // new BsonDocument("totalAmount", + // new BsonDocument("$gt", 0)))); pipeline.Add( new BsonDocument("$setWindowFields", new BsonDocument { diff --git a/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs b/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs index f539820..8a86167 100644 --- a/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs +++ b/src/ApiServer/ApiServer/Utilities/CsvFileHelper.cs @@ -8,28 +8,75 @@ namespace WomPlatform.Web.Api.Utilities; public class CsvFileHelper { - public static byte[] GenerateCsvContent(VoucherGenerationRedemptionStatsResponse genRedResponse, VoucherConsumptionStatsResponse consumedResponse) - { - var csvData = new List - { - new { Category = "Totale WOM Consumed", Value = consumedResponse.TotalConsumed }, - new { Category = "Totale WOM Generated", Value = genRedResponse.TotalGenerated }, - new { Category = "Totale WOM Redeemed", Value = genRedResponse.TotalRedeemed } - }; - - csvData.AddRange(consumedResponse.TotalConsumedOverTime.Select(item => new { Category = $"Consumed Over Time ({item.Date})", Value = item.Total })); - csvData.AddRange(genRedResponse.TotalGeneratedAndRedeemedOverTime.Select(item => new { Category = $"Generated ({item.Date})", Value = item.TotalGenerated })); - csvData.AddRange(genRedResponse.TotalGeneratedAndRedeemedOverTime.Select(item => new { Category = $"Redeemed ({item.Date})", Value = item.TotalRedeemed })); - csvData.AddRange(consumedResponse.MerchantRanks.Select(item => new { Category = $"Merchant Rank {item.Rank} ({item.Name})", Value = item.Amount })); - csvData.AddRange(consumedResponse.VoucherByAims.Select(item => new { Category = $"Voucher Consumed ({item.AimCode})", Value = item.Amount })); - csvData.AddRange(genRedResponse.VoucherByAim.Select(item => new { Category = $"Voucher Generated ({item.AimCode})", Value = item.Amount })); - csvData.AddRange(genRedResponse.SourceRank.Select(item => new { Category = $"Source Rank {item.Rank} ({item.Name})", Value = item.TotalGeneratedAmount, TotalRedeemedAmount = item.TotalRedeemedAmount })); - - using (var memoryStream = new MemoryStream()) - using (var writer = new StreamWriter(memoryStream, leaveOpen: true)) - using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture)) - { - csv.WriteRecords(csvData); // Write the records to the CSV file + public static byte[] GenerateCsvContent(VoucherGenerationRedemptionStatsResponse genRedResponse, + VoucherConsumptionStatsResponse consumedResponse, int availableResponse, FiltersDTO filters) { + // Start by adding metadata for the filters at the top of the CSV + var csvData = new List(); + + // Add filters information if present + if(filters.StartDate.HasValue) { + csvData.Add(new { Period = "Filter", Metric = "Start Date", Value = filters.StartDate.Value.ToString("yyyy-MM-dd"), Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + } + + if(filters.EndDate.HasValue) { + csvData.Add(new { Period = "Filter", Metric = "End Date", Value = filters.EndDate.Value.ToString("yyyy-MM-dd"), Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + } + + if(filters.SourceId.HasValue) { + csvData.Add(new { Period = "Filter", Metric = "Source ID", Value = filters.SourceId.Value.ToString(), Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + } + + if(filters.MerchantId.HasValue) { + csvData.Add(new { Period = "Filter", Metric = "Merchant ID", Value = filters.MerchantId.Value.ToString(), Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + } + + if(filters.AimFilter != null && filters.AimFilter.Length > 0) { + csvData.Add(new { Period = "Filter", Metric = "Aim Filter", Value = string.Join(", ", filters.AimFilter), Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + } + + // Add the main data rows + csvData.Add(new { Period = "Total", Metric = "Consumed", Value = consumedResponse, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + csvData.Add(new { Period = "Total", Metric = "Available", Value = availableResponse, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + csvData.Add(new { Period = "Total", Metric = "Generated", Value = genRedResponse.TotalGenerated, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + csvData.Add(new { Period = "Total", Metric = "Redeemed", Value = genRedResponse.TotalRedeemed, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" }); + + csvData.AddRange(consumedResponse.TotalConsumedOverTime.Select(item => new { + Period = item.Date, Metric = "Consumed", Value = item.Total, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" + })); + + csvData.AddRange(genRedResponse.TotalGeneratedAndRedeemedOverTime.Select(item => new { + Period = item.Date, Metric = "Generated", Value = item.TotalGenerated, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" + })); + + csvData.AddRange(genRedResponse.TotalGeneratedAndRedeemedOverTime.Select(item => new { + Period = item.Date, Metric = "Redeemed", Value = item.TotalRedeemed, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = "" + })); + + csvData.AddRange(consumedResponse.MerchantRanks.Select(item => new { + Period = "", Metric = "Rank", Value = item.Amount, Rank_Type = "Merchant", Rank_Position = item.Rank, Rank_Name = item.Name, Aim = "" + })); + + csvData.AddRange(genRedResponse.SourceRank.Select(item => new { + Period = "", Metric = "Rank", Value = item.TotalGeneratedAmount, Rank_Type = "Instrument Generated", Rank_Position = item.Rank, Rank_Name = item.Name, Aim = "" + })); + + csvData.AddRange(genRedResponse.SourceRank.Select(item => new { + Period = "", Metric = "Rank", Value = item.TotalRedeemedAmount, Rank_Type = "Instrument Redeemed", Rank_Position = item.Rank, Rank_Name = item.Name, Aim = "" + })); + + csvData.AddRange(consumedResponse.VoucherByAims.Select(item => new { + Period = "", Metric = "Consumed by Aim", Value = item.Amount, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = item.AimCode + })); + + csvData.AddRange(genRedResponse.VoucherByAim.Select(item => new { + Period = "", Metric = "Generated by Aim", Value = item.Amount, Rank_Type = "", Rank_Position = "", Rank_Name = "", Aim = item.AimCode + })); + + // Write the records to CSV + using(var memoryStream = new MemoryStream()) + using(var writer = new StreamWriter(memoryStream, leaveOpen: true)) + using(var csv = new CsvWriter(writer, CultureInfo.InvariantCulture)) { + csv.WriteRecords(csvData); // Write the records to the CSV file writer.Flush(); // Ensure all data is written to the memory stream return memoryStream.ToArray(); // Return the CSV data as a byte array }