-
Notifications
You must be signed in to change notification settings - Fork 0
feat(phase10): ship Wave 4 admin Reports backend (Wave 4.1) #262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
39 commits
Select commit
Hold shift + click to select a range
71583fe
test(frontend): restore SearchForm showPicker teardown
8e93b54
feat(phase10): close payment/reservation module thresholds, start adm…
b0d7c82
fix(tests): codex review fixes - reservation overlap mock param + sho…
6e3ee3e
test(phase10): expand admin reservations coverage
60f6187
Merge remote-tracking branch 'origin/main' into feat/phase10-public-p…
642a5ee
docs(phase10): add admin reservations PR handoff
483ab7d
test(phase10): lift frontend coverage past 25 percent
e3d37d8
merge(main): resolve phase10 coverage docs
8d5ec08
test(phase10): expand admin coverage toward launch gate
9508f25
merge(main): resolve phase10 coverage docs
1cce367
docs(phase10): add pr230 coverage handoff
14e756a
test(phase10): close frontend coverage gate
ae0ccda
docs(phase10): align frontend coverage completion notes
e116787
docs(phase10): add frontend coverage PR handoff
aaae296
fix(phase10): stabilize local docker load validation
c6a7ba2
merge(main): resolve phase10 docs conflicts
8810f07
fix(phase10): restore reservation service unit tests
3ef8ddc
fix(phase10): scope reservations and stabilize smoke checks
c35c252
docs(phase10): verify local docker load validation
2e7e646
Merge remote-tracking branch 'origin/main' into feat/phase10-public-p…
e893539
fix(phase10): address codex review comments
c06911e
fix(phase10): preserve reservation tracking on by-id lookup
fbe0597
docs(phase10): close local load baseline
ce167b7
merge: origin/main into feat/phase10-public-page-coverage
382dd09
fix(test): align rate limiting reflection test
28da0ae
fix(phase10): move concurrent booking seed to startup
8a90b70
fix(phase10): address load-baseline review follow-up
544613c
merge: resolve origin/main conflicts for PR #259
46735ea
docs(phase10): archive PR #259 load-baseline closure body and record …
5f4c406
docs: restructure CLAUDE.md to delegate to AGENTS.md
8d57e52
docs(handoff): archive 2026-06-02 paperwork + CLAUDE.md restructure s…
c8def7d
docs(phase10): archive PR #260 body and record Dependabot vitest CVE fix
cb7b345
chore(phase10): finalize preserved working-tree state and ignore loca…
c01f766
docs(phase10): sync PR #260 paperwork, add session handoff, refresh l…
80e7777
docs(phase10): clarify gate #11 references main HEAD not PR branch state
7957132
feat(phase10): ship Wave 4 admin Reports backend (Wave 4.1)
claude 9f92584
docs(phase10): record Wave 4 closure evidence + session handoff
claude 46d57b1
docs(phase10): resolve Wave 4 PR conflict and sync architecture evidence
82510bd
fix(phase10): address Wave 4 reports review feedback
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| namespace RentACar.API.Contracts.Reports; | ||
|
|
||
| public sealed record RevenueReportBreakdownItemResponse( | ||
| DateOnly Date, | ||
| decimal Revenue, | ||
| int Reservations); | ||
|
|
||
| public sealed record RevenueReportResponse( | ||
| string Period, | ||
| decimal TotalRevenue, | ||
| int TotalReservations, | ||
| decimal AverageOrderValue, | ||
| IReadOnlyList<RevenueReportBreakdownItemResponse> DailyBreakdown); | ||
|
|
||
| public sealed record OccupancyReportBreakdownItemResponse( | ||
| DateOnly Date, | ||
| int OccupiedVehicles, | ||
| int TotalVehicles, | ||
| decimal OccupancyRate); | ||
|
|
||
| public sealed record OccupancyReportResponse( | ||
| string Period, | ||
| int TotalVehicles, | ||
| int OccupiedVehicles, | ||
| decimal OccupancyRate, | ||
| IReadOnlyList<OccupancyReportBreakdownItemResponse> DailyBreakdown); | ||
|
|
||
| public sealed record PopularVehicleReportItemResponse( | ||
| string VehicleName, | ||
| int RentalCount, | ||
| decimal Revenue); |
40 changes: 40 additions & 0 deletions
40
backend/src/RentACar.API/Controllers/AdminReportsController.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.AspNetCore.RateLimiting; | ||
| using RentACar.API.Configuration; | ||
| using RentACar.API.Services; | ||
|
|
||
| namespace RentACar.API.Controllers; | ||
|
|
||
| [Route("api/admin/v1/reports")] | ||
| [Authorize(Policy = AuthPolicyNames.AdminOnly)] | ||
| [EnableRateLimiting(RateLimitPolicyNames.Standard)] | ||
| public sealed class AdminReportsController(IReportsService reportsService) : BaseApiController | ||
| { | ||
| [HttpGet("revenue")] | ||
| public async Task<IActionResult> GetRevenue( | ||
| [FromQuery] string period, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var result = await reportsService.GetRevenueReportAsync(period, cancellationToken); | ||
| return OkResponse(result); | ||
| } | ||
|
|
||
| [HttpGet("occupancy")] | ||
| public async Task<IActionResult> GetOccupancy( | ||
| [FromQuery] string period, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var result = await reportsService.GetOccupancyReportAsync(period, cancellationToken); | ||
| return OkResponse(result); | ||
| } | ||
|
|
||
| [HttpGet("popular-vehicles")] | ||
| public async Task<IActionResult> GetPopularVehicles( | ||
| [FromQuery] string period, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var result = await reportsService.GetPopularVehiclesAsync(period, cancellationToken); | ||
| return OkResponse(result); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| using RentACar.API.Contracts.Reports; | ||
|
|
||
| namespace RentACar.API.Services; | ||
|
|
||
| public interface IReportsService | ||
| { | ||
| Task<RevenueReportResponse> GetRevenueReportAsync(string period, CancellationToken cancellationToken = default); | ||
|
|
||
| Task<OccupancyReportResponse> GetOccupancyReportAsync(string period, CancellationToken cancellationToken = default); | ||
|
|
||
| Task<IReadOnlyList<PopularVehicleReportItemResponse>> GetPopularVehiclesAsync(string period, CancellationToken cancellationToken = default); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,249 @@ | ||
| using Microsoft.EntityFrameworkCore; | ||
| using RentACar.API.Contracts.Reports; | ||
| using RentACar.Core.Entities; | ||
| using RentACar.Core.Enums; | ||
| using RentACar.Core.Interfaces; | ||
|
|
||
| namespace RentACar.API.Services; | ||
|
|
||
| public sealed class ReportsService(IApplicationDbContext dbContext) : IReportsService | ||
| { | ||
| private const int PopularVehiclesTopN = 5; | ||
|
|
||
| private static readonly HashSet<ReservationStatus> RevenueEligibleStatuses = new() | ||
| { | ||
| ReservationStatus.Paid, | ||
| ReservationStatus.Active, | ||
| ReservationStatus.Completed | ||
| }; | ||
|
|
||
| public async Task<RevenueReportResponse> GetRevenueReportAsync( | ||
| string period, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| var range = ResolvePeriod(period); | ||
| if (range is null) | ||
| { | ||
| return EmptyRevenueReport(period); | ||
| } | ||
|
|
||
| var (startUtc, endUtc, days) = range.Value; | ||
|
|
||
| var reservations = await dbContext.Reservations | ||
| .AsNoTracking() | ||
| .Where(r => RevenueEligibleStatuses.Contains(r.Status) | ||
| && r.PickupDateTime >= startUtc | ||
| && r.PickupDateTime < endUtc) | ||
| .Select(r => new { r.Id, r.PickupDateTime }) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| var reservationIds = reservations.Select(r => r.Id).ToList(); | ||
| var reservationPickupLookup = reservations.ToDictionary(r => r.Id, r => r.PickupDateTime); | ||
|
|
||
| var paymentIntents = await dbContext.PaymentIntents | ||
| .AsNoTracking() | ||
| .Where(p => p.Status == PaymentStatus.Succeeded | ||
| && reservationIds.Contains(p.ReservationId)) | ||
| .Select(p => new { p.Amount, p.ReservationId }) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| var totalRevenue = paymentIntents.Sum(p => p.Amount); | ||
| var totalReservations = reservations.Count; | ||
| var averageOrderValue = totalReservations > 0 | ||
| ? Math.Round(totalRevenue / totalReservations, 2) | ||
| : 0m; | ||
|
|
||
| var breakdown = days | ||
| .Select(day => | ||
| { | ||
| var dayStart = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); | ||
| var dayEnd = dayStart.AddDays(1); | ||
|
|
||
| var dayRevenue = paymentIntents | ||
| .Where(p => reservationPickupLookup[p.ReservationId] >= dayStart | ||
| && reservationPickupLookup[p.ReservationId] < dayEnd) | ||
| .Sum(p => p.Amount); | ||
|
|
||
| var dayReservations = reservations | ||
| .Count(r => r.PickupDateTime >= dayStart && r.PickupDateTime < dayEnd); | ||
|
|
||
| return new RevenueReportBreakdownItemResponse(day, dayRevenue, dayReservations); | ||
| }) | ||
| .ToList(); | ||
|
|
||
| return new RevenueReportResponse( | ||
| period, | ||
| totalRevenue, | ||
| totalReservations, | ||
| averageOrderValue, | ||
| breakdown); | ||
| } | ||
|
|
||
| public async Task<OccupancyReportResponse> GetOccupancyReportAsync( | ||
| string period, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| var range = ResolvePeriod(period); | ||
| if (range is null) | ||
| { | ||
| return EmptyOccupancyReport(period); | ||
| } | ||
|
|
||
| var (startUtc, endUtc, days) = range.Value; | ||
|
|
||
| var totalVehicles = await dbContext.Vehicles | ||
| .AsNoTracking() | ||
| .CountAsync(cancellationToken); | ||
|
|
||
| var reservations = await dbContext.Reservations | ||
| .AsNoTracking() | ||
| .Where(r => RevenueEligibleStatuses.Contains(r.Status) | ||
| && r.PickupDateTime < endUtc | ||
| && r.ReturnDateTime > startUtc) | ||
| .Select(r => new { r.PickupDateTime, r.ReturnDateTime }) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| var breakdown = days | ||
| .Select(day => | ||
| { | ||
| var dayStart = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); | ||
| var dayEnd = dayStart.AddDays(1); | ||
|
|
||
| var occupied = reservations.Count(r => | ||
| r.PickupDateTime < dayEnd && r.ReturnDateTime > dayStart); | ||
|
|
||
| var rate = totalVehicles > 0 | ||
| ? Math.Round((decimal)occupied / totalVehicles * 100m, 2) | ||
| : 0m; | ||
|
|
||
| return new OccupancyReportBreakdownItemResponse(day, occupied, totalVehicles, rate); | ||
| }) | ||
| .ToList(); | ||
|
|
||
| var lastBucket = breakdown[^1]; | ||
| var overallRate = lastBucket.OccupancyRate; | ||
| var totalOccupied = lastBucket.OccupiedVehicles; | ||
|
|
||
| return new OccupancyReportResponse( | ||
| period, | ||
| totalVehicles, | ||
| totalOccupied, | ||
| overallRate, | ||
| breakdown); | ||
| } | ||
|
|
||
| public async Task<IReadOnlyList<PopularVehicleReportItemResponse>> GetPopularVehiclesAsync( | ||
| string period, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| var range = ResolvePeriod(period); | ||
| if (range is null) | ||
| { | ||
| return Array.Empty<PopularVehicleReportItemResponse>(); | ||
| } | ||
|
|
||
| var (startUtc, endUtc, _) = range.Value; | ||
|
|
||
| var scopedReservations = await dbContext.Reservations | ||
| .AsNoTracking() | ||
| .Where(r => RevenueEligibleStatuses.Contains(r.Status) | ||
| && r.PickupDateTime >= startUtc | ||
| && r.PickupDateTime < endUtc) | ||
| .Select(r => new { r.Id, r.VehicleId }) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| var grouped = scopedReservations | ||
| .GroupBy(r => r.VehicleId) | ||
| .Select(g => new | ||
| { | ||
| VehicleId = g.Key, | ||
| RentalCount = g.Count() | ||
| }) | ||
| .ToList(); | ||
|
|
||
| if (grouped.Count == 0) | ||
| { | ||
| return Array.Empty<PopularVehicleReportItemResponse>(); | ||
| } | ||
|
|
||
| var vehicleIds = grouped.Select(g => g.VehicleId).ToList(); | ||
|
|
||
| var vehicles = await dbContext.Vehicles | ||
| .AsNoTracking() | ||
| .Where(v => vehicleIds.Contains(v.Id)) | ||
| .Select(v => new { v.Id, v.Brand, v.Model }) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| var reservationVehicleLookup = scopedReservations.ToDictionary(r => r.Id, r => r.VehicleId); | ||
| var reservationIds = reservationVehicleLookup.Keys.ToList(); | ||
|
|
||
| var paymentAmounts = await dbContext.PaymentIntents | ||
| .AsNoTracking() | ||
| .Where(p => p.Status == PaymentStatus.Succeeded | ||
| && reservationIds.Contains(p.ReservationId)) | ||
| .Select(p => new { p.ReservationId, p.Amount }) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| var revenueLookup = paymentAmounts | ||
| .GroupBy(p => reservationVehicleLookup[p.ReservationId]) | ||
| .ToDictionary(g => g.Key, g => g.Sum(p => p.Amount)); | ||
|
|
||
| var result = grouped | ||
| .OrderByDescending(g => g.RentalCount) | ||
| .ThenBy(g => g.VehicleId) | ||
| .Take(PopularVehiclesTopN) | ||
| .Select(g => | ||
| { | ||
| var vehicle = vehicles.FirstOrDefault(v => v.Id == g.VehicleId); | ||
| var name = vehicle is null | ||
| ? "Unknown" | ||
| : $"{vehicle.Brand} {vehicle.Model}".Trim(); | ||
| var revenue = revenueLookup.TryGetValue(g.VehicleId, out var r) ? r : 0m; | ||
| return new PopularVehicleReportItemResponse(name, g.RentalCount, revenue); | ||
| }) | ||
| .ToList(); | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| private static (DateTime StartUtc, DateTime EndUtc, IReadOnlyList<DateOnly> Days)? ResolvePeriod(string? period) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(period)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| var normalized = period.Trim().ToLowerInvariant(); | ||
| var dayCount = normalized switch | ||
| { | ||
| "daily" => 1, | ||
| "weekly" => 7, | ||
| "monthly" => 30, | ||
| "quarterly" => 90, | ||
| "yearly" => 365, | ||
| _ => -1 | ||
| }; | ||
|
|
||
| if (dayCount < 0) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| var today = DateTime.UtcNow.Date; | ||
| var startDate = today.AddDays(-(dayCount - 1)); | ||
| var endUtc = today.AddDays(1); | ||
| var startUtc = startDate; | ||
|
|
||
| var days = Enumerable.Range(0, dayCount) | ||
| .Select(i => DateOnly.FromDateTime(startDate.AddDays(i))) | ||
| .ToList(); | ||
|
|
||
| return (startUtc, endUtc, days); | ||
| } | ||
|
|
||
| private static RevenueReportResponse EmptyRevenueReport(string? period) => | ||
| new(period ?? string.Empty, 0m, 0, 0m, Array.Empty<RevenueReportBreakdownItemResponse>()); | ||
|
|
||
| private static OccupancyReportResponse EmptyOccupancyReport(string? period) => | ||
| new(period ?? string.Empty, 0, 0, 0m, Array.Empty<OccupancyReportBreakdownItemResponse>()); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For weekly/monthly/yearly occupancy reports after the database has historical completed rentals, this query materializes every paid/active/completed reservation because it never applies the resolved
startUtc/endUtcoverlap window. The later per-day loop only needs reservations wherePickupDateTime < endUtc && ReturnDateTime > startUtc; without that predicate the endpoint doesdays * allHistoricalReservationsin memory and can become slow or memory-heavy as booking history grows.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 82510bd: occupancy reservations are now filtered by the requested report-window overlap before per-day aggregation. Added service coverage for out-of-period reservations.