Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/TransportPlatform.Api/Endpoints/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using TransportPlatform.Application.Common;
using TransportPlatform.Application.Companies;
using TransportPlatform.Application.Notifications;
using TransportPlatform.Application.Reports;
using TransportPlatform.Domain.Companies;

namespace TransportPlatform.Api.Endpoints;
Expand Down Expand Up @@ -75,6 +76,16 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder
.WithName("NotifyCompany")
.WithSummary("Send an in-app notification to a company's manager(s).");

// ── Admin reports ─────────────────────────────────────────────────────────────
var reports = app.MapGroup("/api/admin/reports").WithTags("Admin · Reports")
.RequireAuthorization(AuthorizationPolicies.AdminOnly)
.RequireRateLimiting(RateLimitPolicies.Sensitive);

reports.MapGet("/summary", async (AdminSystemSummaryHandler handler, CancellationToken ct) =>
Results.Ok(await handler.HandleAsync(ct)))
.WithName("AdminSystemSummary")
.WithSummary("Platform-wide totals (companies, trips, bookings, revenue).");

return app;
}
}
35 changes: 35 additions & 0 deletions src/TransportPlatform.Api/Endpoints/VendorEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Text;
using FluentValidation;
using TransportPlatform.Api.Security;
using TransportPlatform.Application.Common;
using TransportPlatform.Application.Fleet;
using TransportPlatform.Application.Reports;
using TransportPlatform.Application.Staff;
using TransportPlatform.Application.Trips;
using TransportPlatform.Domain.Fleet;
Expand Down Expand Up @@ -129,6 +131,39 @@ public static IEndpointRouteBuilder MapVendorEndpoints(this IEndpointRouteBuilde
.WithName("AssignBusDriver")
.WithSummary("Assign (or clear) the driver of one of your buses.");

// ── Reports + demand ────────────────────────────────────────────────────────
group.MapGet("/reports/summary", async (
DateTimeOffset? from, DateTimeOffset? to, VendorReportSummaryHandler handler, CancellationToken ct) =>
Results.Ok(await handler.HandleAsync(new VendorReportQuery(from, to), ct)))
.WithName("VendorReportSummary")
.WithSummary("Financial + occupancy summary for your company over a date range.");

group.MapGet("/reports/trips", async (
DateTimeOffset? from, DateTimeOffset? to, VendorTripReportHandler handler, CancellationToken ct) =>
Results.Ok(await handler.HandleAsync(new VendorReportQuery(from, to), ct)))
.WithName("VendorTripReport")
.WithSummary("Per-trip occupancy + revenue for your company.");

group.MapGet("/reports/trips/export", async (
DateTimeOffset? from, DateTimeOffset? to, VendorTripReportHandler handler, CancellationToken ct) =>
{
var csv = await handler.ExportCsvAsync(new VendorReportQuery(from, to), ct);
return Results.File(Encoding.UTF8.GetBytes(csv), "text/csv", "trips-report.csv");
})
.WithName("VendorTripReportCsv")
.WithSummary("Download the per-trip report as CSV.");

group.MapGet("/demand/predict", async (
string origin, string destination, DateOnly date,
PredictDemandHandler handler, IValidator<PredictDemandQuery> validator, CancellationToken ct) =>
{
var query = new PredictDemandQuery(origin, destination, date);
await validator.ValidateAndThrowAsync(query, ct);
return Results.Ok(await handler.HandleAsync(query, ct));
})
.WithName("PredictDemand")
.WithSummary("Forecast demand for a route/date from your company's history.");

return app;
}
}
40 changes: 40 additions & 0 deletions src/TransportPlatform.Application/Common/CsvWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Globalization;
using System.Text;

namespace TransportPlatform.Application.Common;

/// <summary>
/// Minimal, dependency-free CSV builder. RFC-4180 quoting PLUS formula-injection defense: a field
/// beginning with = + - @ (or a tab/CR) is prefixed with a single quote so spreadsheet apps don't
/// execute it as a formula (per the security skill).
/// </summary>
public static class CsvWriter
{
public static string Build(IReadOnlyList<string> header, IEnumerable<IReadOnlyList<string>> rows)
{
var sb = new StringBuilder();
sb.AppendLine(string.Join(',', header.Select(Escape)));
foreach (var row in rows)
sb.AppendLine(string.Join(',', row.Select(Escape)));
return sb.ToString();
}

public static string Cell(decimal value) => value.ToString("0.00", CultureInfo.InvariantCulture);
public static string Cell(int value) => value.ToString(CultureInfo.InvariantCulture);
public static string Cell(DateTimeOffset value) => value.ToString("u", CultureInfo.InvariantCulture);

private static string Escape(string? field)
{
var value = field ?? string.Empty;

// Formula-injection guard: neutralize a leading active character.
if (value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r')
value = "'" + value;

// RFC-4180 quoting when the value contains a comma, quote, or newline.
if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'))
value = "\"" + value.Replace("\"", "\"\"") + "\"";

return value;
}
}
7 changes: 7 additions & 0 deletions src/TransportPlatform.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using TransportPlatform.Application.Identity;
using TransportPlatform.Application.Notifications;
using TransportPlatform.Application.Payments;
using TransportPlatform.Application.Reports;
using TransportPlatform.Application.Staff;
using TransportPlatform.Application.Trips;

Expand Down Expand Up @@ -68,6 +69,12 @@ public static IServiceCollection AddApplication(this IServiceCollection services
services.AddScoped<MarkAllNotificationsReadHandler>();
services.AddScoped<NotifyCompanyHandler>();

// Reports + demand
services.AddScoped<VendorReportSummaryHandler>();
services.AddScoped<VendorTripReportHandler>();
services.AddScoped<AdminSystemSummaryHandler>();
services.AddScoped<PredictDemandHandler>();

return services;
}
}
27 changes: 27 additions & 0 deletions src/TransportPlatform.Application/Reports/AdminReports.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using TransportPlatform.Application.Common;
using TransportPlatform.Domain.Bookings;
using TransportPlatform.Domain.Companies;
using TransportPlatform.Domain.Payments;

namespace TransportPlatform.Application.Reports;

public sealed record AdminSystemSummary(
int Companies, int ActiveCompanies, int Trips, int ConfirmedBookings, decimal Revenue);

/// <summary>Platform-wide totals for the admin dashboard (SQL aggregation only).</summary>
public sealed class AdminSystemSummaryHandler(IApplicationDbContext db)
{
public async Task<AdminSystemSummary> HandleAsync(CancellationToken ct)
{
var companies = await db.Companies.CountAsync(ct);
var activeCompanies = await db.Companies.CountAsync(c => c.Status == CompanyStatus.Active, ct);
var trips = await db.Trips.CountAsync(ct);
var confirmedBookings = await db.Bookings.CountAsync(b => b.Status == BookingStatus.Confirmed, ct);
var revenue = await db.Payments
.Where(p => p.Status == PaymentStatus.Completed)
.SumAsync(p => (decimal?)p.Amount, ct) ?? 0m;

return new AdminSystemSummary(companies, activeCompanies, trips, confirmedBookings, revenue);
}
}
79 changes: 79 additions & 0 deletions src/TransportPlatform.Application/Reports/PredictDemand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TransportPlatform.Application.Common;

namespace TransportPlatform.Application.Reports;

public sealed record PredictDemandQuery(string Origin, string Destination, DateOnly Date);

public sealed record DemandPrediction(
string Origin, string Destination, DateOnly Date,
int PredictedBookings, string Confidence, int SampleSize);

public sealed class PredictDemandValidator : AbstractValidator<PredictDemandQuery>
{
public PredictDemandValidator()
{
RuleFor(x => x.Origin).NotEmpty().MaximumLength(120);
RuleFor(x => x.Destination).NotEmpty().MaximumLength(120);
}
}

/// <summary>
/// Statistical demand forecast for a route/date from the company's own last-90-days history:
/// average seats sold on the target day-of-week, adjusted by a recent-vs-earlier trend and a
/// seasonal factor (weekend / summer / Dec-Jan). Confidence scales with the sample size. No ML.
/// </summary>
public sealed class PredictDemandHandler(IApplicationDbContext db, ICurrentUser currentUser, IClock clock)
{
private const int HistoryDays = 90;

public async Task<DemandPrediction> HandleAsync(PredictDemandQuery query, CancellationToken ct)
{
var companyId = currentUser.RequireCompanyId();
var now = clock.UtcNow;
var windowStart = now.AddDays(-HistoryDays);
var origin = query.Origin.Trim().ToLowerInvariant();
var destination = query.Destination.Trim().ToLowerInvariant();

// Per past trip on this route: how many seats were actually sold. Bounded (≤ trips in 90d).
#pragma warning disable CA1304, CA1311, CA1862 // ToLower() is translated to SQL lower(); no culture involved
var history = await db.Trips
.Where(t => t.CompanyId == companyId
&& t.DepartureUtc >= windowStart && t.DepartureUtc < now
&& t.Origin.ToLower() == origin && t.Destination.ToLower() == destination)
.Select(t => new { t.DepartureUtc, Demand = db.SeatAssignments.Count(a => a.TripId == t.Id) })
.ToListAsync(ct);
#pragma warning restore CA1304, CA1311, CA1862

var sampleSize = history.Count;
if (sampleSize == 0)
return new DemandPrediction(query.Origin, query.Destination, query.Date, 0, "low", 0);

// Baseline: average demand on the target day-of-week (fall back to overall average).
var dow = query.Date.DayOfWeek;
var sameDow = history.Where(h => h.DepartureUtc.DayOfWeek == dow).Select(h => h.Demand).ToList();
var baseline = sameDow.Count > 0 ? sameDow.Average() : history.Average(h => h.Demand);

// Trend: recent 30 days vs the prior 60 days.
var recentCut = now.AddDays(-30);
var recent = history.Where(h => h.DepartureUtc >= recentCut).Select(h => h.Demand).ToList();
var earlier = history.Where(h => h.DepartureUtc < recentCut).Select(h => h.Demand).ToList();
var trend = recent.Count > 0 && earlier.Count > 0 && earlier.Average() > 0
? Math.Clamp(recent.Average() / earlier.Average(), 0.5, 2.0)
: 1.0;

var predicted = (int)Math.Round(baseline * trend * SeasonalFactor(query.Date));
var confidence = sampleSize >= 12 ? "high" : sampleSize >= 4 ? "medium" : "low";
return new DemandPrediction(query.Origin, query.Destination, query.Date, Math.Max(0, predicted), confidence, sampleSize);
}

private static double SeasonalFactor(DateOnly date)
{
var factor = 1.0;
if (date.DayOfWeek is DayOfWeek.Friday or DayOfWeek.Saturday) factor *= 1.2; // weekend travel
if (date.Month is 6 or 7 or 8) factor *= 1.1; // summer
if (date.Month is 12 or 1) factor *= 1.2; // holidays
return factor;
}
}
94 changes: 94 additions & 0 deletions src/TransportPlatform.Application/Reports/VendorReports.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using TransportPlatform.Application.Common;
using TransportPlatform.Domain.Bookings;
using TransportPlatform.Domain.Payments;

namespace TransportPlatform.Application.Reports;

public sealed record VendorReportQuery(DateTimeOffset? From, DateTimeOffset? To);

public sealed record VendorReportSummary(
DateTimeOffset From, DateTimeOffset To, int Trips, int ConfirmedBookings,
int SeatsSold, int SeatsOffered, decimal Revenue, string Currency, double OccupancyPct);

/// <summary>
/// Company-scoped financial/occupancy summary over a date range (by trip departure). All numbers
/// are computed with SQL aggregation (no in-memory summing). Revenue = completed payments for the
/// company's bookings in range.
/// </summary>
public sealed class VendorReportSummaryHandler(IApplicationDbContext db, ICurrentUser currentUser, IClock clock)
{
public async Task<VendorReportSummary> HandleAsync(VendorReportQuery query, CancellationToken ct)
{
var companyId = currentUser.RequireCompanyId();
var from = query.From ?? clock.UtcNow.AddMonths(-1);
var to = query.To ?? clock.UtcNow.AddMonths(1);

var trips = db.Trips.Where(t => t.CompanyId == companyId && t.DepartureUtc >= from && t.DepartureUtc < to);
var tripIds = trips.Select(t => t.Id);

var tripCount = await trips.CountAsync(ct);
var seatsOffered = await trips.SumAsync(t => (int?)t.SeatCount, ct) ?? 0;
var currency = await trips.Select(t => t.Currency).FirstOrDefaultAsync(ct) ?? "SYP";

var bookingIds = db.Bookings.Where(b => tripIds.Contains(b.TripId));
var confirmedBookings = await bookingIds.CountAsync(b => b.Status == BookingStatus.Confirmed, ct);
var seatsSold = await db.SeatAssignments.CountAsync(a => tripIds.Contains(a.TripId), ct);

var revenue = await db.Payments
.Where(p => p.Status == PaymentStatus.Completed && bookingIds.Select(b => b.Id).Contains(p.BookingId))
.SumAsync(p => (decimal?)p.Amount, ct) ?? 0m;

var occupancy = seatsOffered > 0 ? Math.Round(seatsSold * 100.0 / seatsOffered, 1) : 0;
return new VendorReportSummary(from, to, tripCount, confirmedBookings, seatsSold, seatsOffered, revenue, currency, occupancy);
}
}

public sealed record TripReportRow(
Guid TripId, string Origin, string Destination, DateTimeOffset DepartureUtc,
int SeatCount, int SeatsSold, decimal Revenue, string Currency, string Status);

/// <summary>Per-trip occupancy + revenue rows for the company (used by the list + CSV export).</summary>
public sealed class VendorTripReportHandler(IApplicationDbContext db, ICurrentUser currentUser, IClock clock)
{
public async Task<IReadOnlyList<TripReportRow>> HandleAsync(VendorReportQuery query, CancellationToken ct)
{
var companyId = currentUser.RequireCompanyId();
var from = query.From ?? clock.UtcNow.AddMonths(-1);
var to = query.To ?? clock.UtcNow.AddMonths(1);

// Seats sold per trip (single grouped query — no N+1).
var trips = await db.Trips
.Where(t => t.CompanyId == companyId && t.DepartureUtc >= from && t.DepartureUtc < to)
.OrderByDescending(t => t.DepartureUtc)
.Select(t => new
{
t.Id, t.Origin, t.Destination, t.DepartureUtc, t.SeatCount, t.Currency,
Status = t.Status.ToString(),
SeatsSold = db.SeatAssignments.Count(a => a.TripId == t.Id),
Revenue = db.Payments
.Where(p => p.Status == PaymentStatus.Completed
&& db.Bookings.Any(b => b.Id == p.BookingId && b.TripId == t.Id))
.Sum(p => (decimal?)p.Amount) ?? 0m,
})
.ToListAsync(ct);

return trips
.Select(t => new TripReportRow(t.Id, t.Origin, t.Destination, t.DepartureUtc,
t.SeatCount, t.SeatsSold, t.Revenue, t.Currency, t.Status))
.ToList();
}

/// <summary>CSV (formula-injection-safe) of the per-trip report.</summary>
public async Task<string> ExportCsvAsync(VendorReportQuery query, CancellationToken ct)
{
var rows = await HandleAsync(query, ct);
return CsvWriter.Build(
["TripId", "Origin", "Destination", "DepartureUtc", "Seats", "SeatsSold", "Revenue", "Currency", "Status"],
rows.Select(r => new[]
{
r.TripId.ToString(), r.Origin, r.Destination, CsvWriter.Cell(r.DepartureUtc),
CsvWriter.Cell(r.SeatCount), CsvWriter.Cell(r.SeatsSold), CsvWriter.Cell(r.Revenue), r.Currency, r.Status,
}));
}
}
Loading