From 6bcce94f14f628ae4458251f46b0e380dc18addb Mon Sep 17 00:00:00 2001 From: Nguyen Xuan Nhan Date: Thu, 6 Jun 2024 00:49:07 +0700 Subject: [PATCH] feat: revenue year report --- .../Endpoints/Reports/RevenueYear.Request.cs | 3 ++ .../Endpoints/Reports/RevenueYear.cs | 29 +++++++++++++ .../Products/Workers/CalculateRatingWorker.cs | 4 +- .../Reports/DTOs/RevenueYearDto.cs | 7 +++ .../Queries/RevenueYear/RevenueYearHandler.cs | 43 +++++++++++++++++++ .../Queries/RevenueYear/RevenueYearQuery.cs | 7 +++ .../RevenueYear/RevenueYearValidator.cs | 8 ++++ .../Specifications/ProductsFilterSpec.cs | 3 +- 8 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.Request.cs create mode 100644 src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.cs create mode 100644 src/RookieShop.Application/Reports/DTOs/RevenueYearDto.cs create mode 100644 src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearHandler.cs create mode 100644 src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearQuery.cs create mode 100644 src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearValidator.cs diff --git a/src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.Request.cs b/src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.Request.cs new file mode 100644 index 0000000..33ec8a2 --- /dev/null +++ b/src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.Request.cs @@ -0,0 +1,3 @@ +namespace RookieShop.ApiService.Endpoints.Reports; + +public sealed record RevenueYearRequest(int Year); \ No newline at end of file diff --git a/src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.cs b/src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.cs new file mode 100644 index 0000000..5806835 --- /dev/null +++ b/src/RookieShop.ApiService/Endpoints/Reports/RevenueYear.cs @@ -0,0 +1,29 @@ +using MediatR; +using Microsoft.AspNetCore.Http.HttpResults; +using RookieShop.Application.Reports.DTOs; +using RookieShop.Application.Reports.Queries.RevenueYear; +using RookieShop.Infrastructure.Endpoints.Abstractions; +using RookieShop.Infrastructure.RateLimiter; + +namespace RookieShop.ApiService.Endpoints.Reports; + +public sealed class RevenueYear(ISender sender) : IEndpoint>, RevenueYearRequest> +{ + public void MapEndpoint(IEndpointRouteBuilder app) => + app.MapGet("/reports/revenue-year", async (int year) => await HandleAsync(new(year))) + .Produces>>() + .WithTags(nameof(Reports)) + .WithName("Revenue Year") + .MapToApiVersion(new(1, 0)) + .RequirePerIpRateLimit(); + + public async Task>> HandleAsync(RevenueYearRequest request, + CancellationToken cancellationToken = default) + { + RevenueYearQuery query = new(request.Year); + + var result = await sender.Send(query, cancellationToken); + + return TypedResults.Ok(result.Value.ToList()); + } +} \ No newline at end of file diff --git a/src/RookieShop.Application/Products/Workers/CalculateRatingWorker.cs b/src/RookieShop.Application/Products/Workers/CalculateRatingWorker.cs index b211485..a936142 100644 --- a/src/RookieShop.Application/Products/Workers/CalculateRatingWorker.cs +++ b/src/RookieShop.Application/Products/Workers/CalculateRatingWorker.cs @@ -47,9 +47,7 @@ protected override async Task DoWork(CancellationToken stoppingToken) if (product is null) continue; - product.AverageRating = feedback.AverageRating; - - product.TotalReviews = feedback.TotalFeedback; + product.UpdateRating(feedback.AverageRating, feedback.TotalFeedback); dbContext.Products.Update(product); } diff --git a/src/RookieShop.Application/Reports/DTOs/RevenueYearDto.cs b/src/RookieShop.Application/Reports/DTOs/RevenueYearDto.cs new file mode 100644 index 0000000..96f238a --- /dev/null +++ b/src/RookieShop.Application/Reports/DTOs/RevenueYearDto.cs @@ -0,0 +1,7 @@ +namespace RookieShop.Application.Reports.DTOs; + +public sealed class RevenueYearDto +{ + public string? Month { get; set; } + public decimal Revenue { get; set; } +} \ No newline at end of file diff --git a/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearHandler.cs b/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearHandler.cs new file mode 100644 index 0000000..1ba6ebf --- /dev/null +++ b/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearHandler.cs @@ -0,0 +1,43 @@ +using Ardalis.Result; +using Dapper; +using RookieShop.Application.Reports.DTOs; +using RookieShop.Domain.SharedKernel; +using RookieShop.Persistence; + +namespace RookieShop.Application.Reports.Queries.RevenueYear; + +public sealed class RevenueYearHandler(IDatabaseFactory factory) + : IQueryHandler>> +{ + public async Task>> Handle(RevenueYearQuery request, + CancellationToken cancellationToken) + { + const string sql = $""" + WITH monthly_revenue AS ( + SELECT + to_char(o.created_date, 'Mon') AS month, + SUM(od.price * od.quantity) AS revenue + FROM orders o + JOIN order_details od ON o.id = od.order_id + WHERE EXTRACT(YEAR FROM o.created_date) = @year + GROUP BY to_char(o.created_date, 'Mon') + ) + SELECT + month_names.month AS {nameof(RevenueYearDto.Month)}, + COALESCE(monthly_revenue.revenue, 0) AS {nameof(RevenueYearDto.Revenue)} + FROM ( + VALUES ('Jan'), ('Feb'), ('Mar'), ('Apr'), ('May'), ('Jun'), + ('Jul'), ('Aug'), ('Sep'), ('Oct'), ('Nov'), ('Dec') + ) AS month_names(month) + LEFT JOIN monthly_revenue ON month_names.month = monthly_revenue.month + ORDER BY month_names.month; + """; + + using var conn = factory.GetOpenConnection(); + + var result = await conn.QueryAsync(sql, + new { year = request.Year }); + + return result.ToList(); + } +} \ No newline at end of file diff --git a/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearQuery.cs b/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearQuery.cs new file mode 100644 index 0000000..8c87541 --- /dev/null +++ b/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearQuery.cs @@ -0,0 +1,7 @@ +using Ardalis.Result; +using RookieShop.Application.Reports.DTOs; +using RookieShop.Domain.SharedKernel; + +namespace RookieShop.Application.Reports.Queries.RevenueYear; + +public sealed record RevenueYearQuery(int Year) : IQuery>>; \ No newline at end of file diff --git a/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearValidator.cs b/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearValidator.cs new file mode 100644 index 0000000..cf4c426 --- /dev/null +++ b/src/RookieShop.Application/Reports/Queries/RevenueYear/RevenueYearValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace RookieShop.Application.Reports.Queries.RevenueYear; + +public sealed class RevenueYearValidator : AbstractValidator +{ + public RevenueYearValidator() => RuleFor(x => x.Year).NotEmpty().GreaterThan(2000); +} \ No newline at end of file diff --git a/src/RookieShop.Domain/Entities/ProductAggregator/Specifications/ProductsFilterSpec.cs b/src/RookieShop.Domain/Entities/ProductAggregator/Specifications/ProductsFilterSpec.cs index 01385b5..1353474 100644 --- a/src/RookieShop.Domain/Entities/ProductAggregator/Specifications/ProductsFilterSpec.cs +++ b/src/RookieShop.Domain/Entities/ProductAggregator/Specifications/ProductsFilterSpec.cs @@ -22,6 +22,5 @@ public sealed class ProductsFilterSpec : Specification .ApplyOrdering(orderBy, isDescending); } - public ProductsFilterSpec() => Query - .Where(product => !product.IsDeleted); + public ProductsFilterSpec() => Query.Where(product => !product.IsDeleted); } \ No newline at end of file