diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index e77ed7a..9b7a24d 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Application/Routes/RouteAddressDto.cs b/src/Application/Routes/RouteAddressDto.cs index fbe2148..8fcd4ec 100644 --- a/src/Application/Routes/RouteAddressDto.cs +++ b/src/Application/Routes/RouteAddressDto.cs @@ -10,15 +10,15 @@ public sealed class RouteAddressDto : IMapFrom public short Order { get; set; } - public Guid AddressUuid { get; set; } + public Guid Uuid { get; set; } - public string AddressName { get; set; } + public string Name { get; set; } - public double AddressLongitude { get; set; } + public double Longitude { get; set; } - public double AddressLatitude { get; set; } + public double Latitude { get; set; } - public string AddressVehicleType { get; set; } + public string VehicleType { get; set; } public Guid CountryUuid { get; set; } @@ -39,19 +39,19 @@ public sealed class RouteAddressDto : IMapFrom d => d.RouteAddressUuid, opt => opt.MapFrom(s => s.Guid)) .ForMember( - d => d.AddressUuid, + d => d.Uuid, opt => opt.MapFrom(s => s.Address.Guid)) .ForMember( - d => d.AddressName, + d => d.Name, opt => opt.MapFrom(s => s.Address.Name)) .ForMember( - d => d.AddressLongitude, + d => d.Longitude, opt => opt.MapFrom(s => s.Address.Longitude)) .ForMember( - d => d.AddressLatitude, + d => d.Latitude, opt => opt.MapFrom(s => s.Address.Latitude)) .ForMember( - d => d.AddressVehicleType, + d => d.VehicleType, opt => opt.MapFrom(s => s.Address.VehicleType.Name)) .ForMember( d => d.CityUuid, diff --git a/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQuery.cs b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQuery.cs new file mode 100644 index 0000000..4bdfae6 --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQuery.cs @@ -0,0 +1,21 @@ +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR; + +namespace cuqmbr.TravelGuide.Application + .VehicleEnrollmentSearch.Queries.SearchShortest; + +public record SearchShortestQuery : + IRequest +{ + public Guid DepartureAddressGuid { get; set; } + + public Guid ArrivalAddressGuid { get; set; } + + public DateOnly DepartureDate { get; set; } + + public HashSet VehicleTypes { get; set; } + + public bool ShortestByCost { get; set; } + + public bool ShortestByTime { get; set; } +} diff --git a/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryAuthorizer.cs b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryAuthorizer.cs new file mode 100644 index 0000000..97eda14 --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryAuthorizer.cs @@ -0,0 +1,33 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Application.Common.Models; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application + .VehicleEnrollmentSearch.Queries.SearchShortest; + +public class SearchShortestQueryAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public SearchShortestQueryAuthorizer( + SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(SearchShortestQuery request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryHandler.cs b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryHandler.cs new file mode 100644 index 0000000..5c5f0b3 --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryHandler.cs @@ -0,0 +1,436 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using cuqmbr.TravelGuide.Domain.Entities; +using AutoMapper; +using QuikGraph; +using QuikGraph.Algorithms; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; + +namespace cuqmbr.TravelGuide.Application + .VehicleEnrollmentSearch.Queries.SearchShortest; + +// TODO: Refactor. +// TODO: Add configurable time between transfers. +public class SearchShortestQueryHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + private readonly SessionCurrencyService _sessionCurrencyService; + private readonly CurrencyConverterService _currencyConverterService; + + public SearchShortestQueryHandler( + UnitOfWork unitOfWork, + IMapper mapper, + SessionCurrencyService sessionCurrencyService, + CurrencyConverterService currencyConverterService) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + _sessionCurrencyService = sessionCurrencyService; + _currencyConverterService = currencyConverterService; + } + + public async Task Handle( + SearchShortestQuery request, + CancellationToken cancellationToken) + { + // Get related data + + var zeroTime = TimeOnly.FromTimeSpan(TimeSpan.Zero); + var departureDate = + new DateTimeOffset(request.DepartureDate, zeroTime, TimeSpan.Zero); + + var range = TimeSpan.FromDays(3); + var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository + .GetPageAsync( + e => + e.DepartureTime >= departureDate.Subtract(range) && + e.DepartureTime <= departureDate.Add(range) && + request.VehicleTypes.Contains(e.Vehicle.VehicleType), + e => e.Route, + 1, int.MaxValue, cancellationToken)) + .Items; + + if (vehicleEnrollments.Count == 0) + { + throw new NotFoundException(); + } + + var vehicleEnrollmentIds = vehicleEnrollments.Select(e => e.Id); + var routeAddressDetails = (await _unitOfWork.RouteAddressDetailRepository + .GetPageAsync( + e => vehicleEnrollmentIds.Contains(e.VehicleEnrollmentId), + e => e.RouteAddress.Address.City.Region.Country, + 1, int.MaxValue, cancellationToken)) + .Items; + + + // Hydrate vehicle enrollments with route address details + + foreach (var vehicleEnrollment in vehicleEnrollments) + { + vehicleEnrollment.RouteAddressDetails = routeAddressDetails + .Where(e => e.VehicleEnrollmentId == vehicleEnrollment.Id) + .OrderBy(e => e.RouteAddress.Order) + .ToArray(); + } + + + // Creat and fill graph data structure + + var graph = new AdjacencyGraph< + Address, TaggedEdge>(); + + foreach (var vehicleEnrollment in vehicleEnrollments) + { + var vehicleEnrollmentRouteAddressDetails = routeAddressDetails + .Where(e => e.VehicleEnrollmentId == vehicleEnrollment.Id) + .OrderBy(e => e.RouteAddress.Order); + + for (int i = 1; i < vehicleEnrollmentRouteAddressDetails.Count(); i++) + { + var sourceRouteAddressDetail = + vehicleEnrollmentRouteAddressDetails.ElementAt(i-1); + var targetRouteAddressDetail = + vehicleEnrollmentRouteAddressDetails.ElementAt(i); + + var sourceAddress = + sourceRouteAddressDetail.RouteAddress.Address; + var targetAddress = + targetRouteAddressDetail.RouteAddress.Address; + + var weight = sourceRouteAddressDetail.CostToNextAddress; + + graph.AddVerticesAndEdge( + new TaggedEdge( + sourceAddress, targetAddress, sourceRouteAddressDetail)); + } + } + + + // Find paths + + var departureAddress = routeAddressDetails + .First(e => e.RouteAddress.Address.Guid == request.DepartureAddressGuid) + .RouteAddress.Address; + var arrivalAddress = routeAddressDetails + .First(e => e.RouteAddress.Address.Guid == request.ArrivalAddressGuid) + .RouteAddress.Address; + + + Func, double> weightByCost = + edge => (double)edge.Tag.CostToNextAddress; + + Func, double> weightByTime = + edge => + edge.Tag.TimeToNextAddress.Ticks + + edge.Tag.CurrentAddressStopTime.Ticks; + + Func, double> edgeWeight = + _ => 0; + + if (request.ShortestByCost && request.ShortestByTime) + { + edgeWeight = edge => weightByCost(edge) + weightByTime(edge); + } + else if (request.ShortestByCost) + { + edgeWeight = edge => weightByCost(edge); + } + else if (request.ShortestByTime) + { + edgeWeight = edge => weightByTime(edge); + } + + + var tryGetPaths = graph.ShortestPathsDijkstra(edgeWeight, departureAddress); + + + // Create and hydrate a DTO object + + var vehicleEnrollmentDtos = + new List(); + + var totalTravelTime = TimeSpan.Zero; + var totalCost = (decimal)0; + + if (tryGetPaths(arrivalAddress, out var path)) + { + var firstRouteAddressId = path.First().Tag.RouteAddressId; + long lastRouteAddressId; + Guid lastRouteAddressGuid; + + var addressDtos = new List(); + var addressOrder = (short)1; + var enrollmentTravelTime = TimeSpan.Zero; + var enrollmentCost = (decimal)0; + var enrollmentOrder = (short)1; + + Address source; + Address target; + RouteAddressDetail tag; + RouteAddressDetail nextTag; + + for (int i = 0; i < path.Count() - 1; i++) + { + source = path.Select(e => e.Source).ElementAt(i); + tag = path.Select(e => e.Tag).ElementAt(i); + nextTag = path.Select(e => e.Tag).ElementAt(i+1); + + + totalTravelTime += + tag.TimeToNextAddress + tag.CurrentAddressStopTime; + enrollmentTravelTime += + tag.TimeToNextAddress + tag.CurrentAddressStopTime; + + totalCost += tag.CostToNextAddress; + enrollmentCost += tag.CostToNextAddress; + + addressDtos.Add(new VehicleEnrollmentSearchAddressDto() + { + Uuid = source.Guid, + Name = source.Name, + Longitude = source.Latitude, + Latitude = source.Longitude, + CountryUuid = source.City.Region.Country.Guid, + CountryName = source.City.Region.Country.Name, + RegionUuid = source.City.Region.Guid, + RegionName = source.City.Region.Name, + CityUuid = source.City.Guid, + CityName = source.City.Name, + TimeToNextAddress = tag.TimeToNextAddress, + CostToNextAddress = tag.CostToNextAddress, + CurrentAddressStopTime = tag.CurrentAddressStopTime, + Order = addressOrder, + RouteAddressUuid = tag.RouteAddress.Guid + }); + + addressOrder++; + + + // First address after transfer + if (nextTag.VehicleEnrollmentId != tag.VehicleEnrollmentId) + { + target = path.Select(e => e.Target).ElementAt(i); + + lastRouteAddressGuid = vehicleEnrollments + .Single(e => e.Id == tag.VehicleEnrollmentId) + .RouteAddressDetails + .Select(e => e.RouteAddress) + .OrderBy(e => e.Order) + .SkipWhile(e => e.Order != tag.RouteAddress.Order) + .Take(2) + .ElementAt(1) + .Guid; + + addressDtos.Add(new VehicleEnrollmentSearchAddressDto() + { + Uuid = target.Guid, + Name = target.Name, + Longitude = target.Latitude, + Latitude = target.Longitude, + CountryUuid = target.City.Region.Country.Guid, + CountryName = target.City.Region.Country.Name, + RegionUuid = target.City.Region.Guid, + RegionName = target.City.Region.Name, + CityUuid = target.City.Guid, + CityName = target.City.Name, + TimeToNextAddress = TimeSpan.Zero, + CostToNextAddress = 0, + CurrentAddressStopTime = TimeSpan.Zero, + Order = addressOrder, + RouteAddressUuid = lastRouteAddressGuid + }); + + lastRouteAddressId = vehicleEnrollments + .Single(e => e.Id == tag.VehicleEnrollmentId) + .RouteAddressDetails + .Select(e => e.RouteAddress) + .OrderBy(e => e.Order) + .SkipWhile(e => e.Order != tag.RouteAddress.Order) + .Take(2) + .ElementAt(1) + .Id; + + vehicleEnrollmentDtos.Add( + new VehicleEnrollmentSearchVehicleEnrollmentDto() + { + DepartureTime = tag.VehicleEnrollment + .GetDepartureTime(firstRouteAddressId), + ArrivalTime = tag.VehicleEnrollment + .GetArrivalTime(lastRouteAddressId), + TravelTime = tag.VehicleEnrollment + .GetTravelTime(firstRouteAddressId, + lastRouteAddressId), + TimeMoving = tag.VehicleEnrollment + .GetTimeMoving(firstRouteAddressId, + lastRouteAddressId), + TimeInStops = tag.VehicleEnrollment + .GetTimeInStops(firstRouteAddressId, + lastRouteAddressId), + NumberOfStops = tag.VehicleEnrollment + .GetNumberOfStops(firstRouteAddressId, + lastRouteAddressId), + Currency = tag.VehicleEnrollment.Currency.Name, + Cost = tag.VehicleEnrollment + .GetCost(firstRouteAddressId, + lastRouteAddressId), + VehicleType = source.VehicleType.Name, + Uuid = tag.VehicleEnrollment.Guid, + Order = enrollmentOrder, + Addresses = addressDtos + }); + + firstRouteAddressId = nextTag.RouteAddressId; + + addressDtos = new List(); + addressOrder = (short)1; + enrollmentTravelTime = TimeSpan.Zero; + enrollmentCost = (decimal)0; + enrollmentOrder++; + } + } + + source = path.Select(e => e.Source).Last(); + target = path.Select(e => e.Target).Last(); + tag = path.Select(e => e.Tag).Last(); + nextTag = path.Select(e => e.Tag).Last(); + + addressDtos.Add(new VehicleEnrollmentSearchAddressDto() + { + Uuid = source.Guid, + Name = source.Name, + Longitude = source.Latitude, + Latitude = source.Longitude, + CountryUuid = source.City.Region.Country.Guid, + CountryName = source.City.Region.Country.Name, + RegionUuid = source.City.Region.Guid, + RegionName = source.City.Region.Name, + CityUuid = source.City.Guid, + CityName = source.City.Name, + TimeToNextAddress = tag.TimeToNextAddress, + CostToNextAddress = tag.CostToNextAddress, + CurrentAddressStopTime = tag.CurrentAddressStopTime, + Order = addressOrder, + RouteAddressUuid = tag.RouteAddress.Guid + }); + + lastRouteAddressGuid = vehicleEnrollments + .Single(e => e.Id == tag.VehicleEnrollmentId) + .RouteAddressDetails + .Select(e => e.RouteAddress) + .OrderBy(e => e.Order) + .SkipWhile(e => e.Order != tag.RouteAddress.Order) + .Take(2) + .ElementAt(1) + .Guid; + + addressOrder++; + addressDtos.Add(new VehicleEnrollmentSearchAddressDto() + { + Uuid = target.Guid, + Name = target.Name, + Longitude = target.Latitude, + Latitude = target.Longitude, + CountryUuid = target.City.Region.Country.Guid, + CountryName = target.City.Region.Country.Name, + RegionUuid = target.City.Region.Guid, + RegionName = target.City.Region.Name, + CityUuid = target.City.Guid, + CityName = target.City.Name, + TimeToNextAddress = TimeSpan.Zero, + CostToNextAddress = 0, + CurrentAddressStopTime = TimeSpan.Zero, + Order = addressOrder, + RouteAddressUuid = lastRouteAddressGuid + }); + + lastRouteAddressId = vehicleEnrollments + .Single(e => e.Id == tag.VehicleEnrollmentId) + .RouteAddressDetails + .Select(e => e.RouteAddress) + .OrderBy(e => e.Order) + .SkipWhile(e => e.Order != tag.RouteAddress.Order) + .Take(2) + .ElementAt(1) + .Id; + + vehicleEnrollmentDtos.Add( + new VehicleEnrollmentSearchVehicleEnrollmentDto() + { + DepartureTime = tag.VehicleEnrollment + .GetDepartureTime(firstRouteAddressId), + ArrivalTime = tag.VehicleEnrollment + .GetArrivalTime(lastRouteAddressId), + TravelTime = tag.VehicleEnrollment + .GetTravelTime(firstRouteAddressId, + lastRouteAddressId), + TimeMoving = tag.VehicleEnrollment + .GetTimeMoving(firstRouteAddressId, + lastRouteAddressId), + TimeInStops = tag.VehicleEnrollment + .GetTimeInStops(firstRouteAddressId, + lastRouteAddressId), + NumberOfStops = tag.VehicleEnrollment + .GetNumberOfStops(firstRouteAddressId, + lastRouteAddressId), + Currency = tag.VehicleEnrollment.Currency.Name, + Cost = tag.VehicleEnrollment + .GetCost(firstRouteAddressId, + lastRouteAddressId), + VehicleType = source.VehicleType.Name, + Uuid = tag.VehicleEnrollment.Guid, + Order = enrollmentOrder, + Addresses = addressDtos + }); + } + else + { + throw new NotFoundException(); + } + + + foreach (var vehicleEnrollmentDto in vehicleEnrollmentDtos) + { + foreach (var addressDto in vehicleEnrollmentDto.Addresses) + { + addressDto.CostToNextAddress = await _currencyConverterService + .ConvertAsync(addressDto.CostToNextAddress, + vehicleEnrollments + .First(e => e.Guid == vehicleEnrollmentDto.Uuid) + .Currency, + _sessionCurrencyService.Currency, cancellationToken); + } + + vehicleEnrollmentDto.Currency = vehicleEnrollmentDto.Currency; + vehicleEnrollmentDto.Cost = vehicleEnrollmentDto.Addresses + .Aggregate((decimal)0, + (sum, next) => sum += next.CostToNextAddress); + } + + var cost = vehicleEnrollmentDtos + .Aggregate((decimal)0, + (sum, next) => sum += next.Cost); + + var departureTime = vehicleEnrollmentDtos + .OrderBy(e => e.Order).First().DepartureTime; + var arrivalTime = vehicleEnrollmentDtos + .OrderBy(e => e.Order).Last().ArrivalTime; + var timeInStops = vehicleEnrollmentDtos + .Aggregate(TimeSpan.Zero, (sum, next) => sum += next.TimeInStops); + var numberOfTransfers = vehicleEnrollmentDtos.Count() - 1; + + return new VehicleEnrollmentSearchDto() + { + DepartureTime = departureTime, + ArrivalTime = arrivalTime, + TravelTime = arrivalTime - departureTime, + TimeInStops = timeInStops, + NumberOfTransfers = numberOfTransfers, + Enrollments = vehicleEnrollmentDtos + }; + } +} diff --git a/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryValidator.cs b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryValidator.cs new file mode 100644 index 0000000..19bb4c4 --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/Queries/SearchShortest/SearchShortestQueryValidator.cs @@ -0,0 +1,43 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application + .VehicleEnrollmentSearch.Queries.SearchShortest; + +public class SearchShortestQueryValidator : + AbstractValidator +{ + public SearchShortestQueryValidator( + IStringLocalizer localizer, + SessionCultureService cultureService) + { + RuleFor(v => v.DepartureAddressGuid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + + RuleFor(v => v.ArrivalAddressGuid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + + RuleFor(v => v.DepartureDate) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .GreaterThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow)) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + DateOnly.FromDateTime(DateTime.UtcNow))); + + RuleForEach(v => v.VehicleTypes) + .Must((v, vt) => VehicleType.Enumerations.ContainsValue(vt)) + .WithMessage( + String.Format( + localizer["FluentValidation.MustBeInEnum"], + String.Join( + ", ", + VehicleType.Enumerations.Values.Select(e => e.Name)))); + } +} diff --git a/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchAddressDto.cs b/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchAddressDto.cs new file mode 100644 index 0000000..01b2602 --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchAddressDto.cs @@ -0,0 +1,34 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollmentSearch; + +public sealed class VehicleEnrollmentSearchAddressDto +{ + public Guid Uuid { get; set; } + + public string Name { get; set; } + + public double Longitude { get; set; } + + public double Latitude { get; set; } + + public Guid CountryUuid { get; set; } + + public string CountryName { get; set; } + + public Guid RegionUuid { get; set; } + + public string RegionName { get; set; } + + public Guid CityUuid { get; set; } + + public string CityName { get; set; } + + public TimeSpan TimeToNextAddress { get; set; } + + public decimal CostToNextAddress { get; set; } + + public TimeSpan CurrentAddressStopTime { get; set; } + + public short Order { get; set; } + + public Guid RouteAddressUuid { get; set; } +} diff --git a/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchDto.cs b/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchDto.cs new file mode 100644 index 0000000..2409797 --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchDto.cs @@ -0,0 +1,17 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollmentSearch; + +public sealed class VehicleEnrollmentSearchDto +{ + public DateTimeOffset DepartureTime { get; set; } + + public DateTimeOffset ArrivalTime { get; set; } + + public TimeSpan TravelTime { get; set; } + + public TimeSpan TimeInStops { get; set; } + + public int NumberOfTransfers { get; set; } + + public ICollection + Enrollments { get; set; } +} diff --git a/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchVehicleEnrollmentDto.cs b/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchVehicleEnrollmentDto.cs new file mode 100644 index 0000000..5481f76 --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/VehicleEnrollmentSearchVehicleEnrollmentDto.cs @@ -0,0 +1,29 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollmentSearch; + +public sealed class VehicleEnrollmentSearchVehicleEnrollmentDto +{ + public string VehicleType { get; set; } + + public DateTimeOffset DepartureTime { get; set; } + + public DateTimeOffset ArrivalTime { get; set; } + + public TimeSpan TravelTime { get; set; } + + public TimeSpan TimeMoving { get; set; } + + public TimeSpan TimeInStops { get; set; } + + public int NumberOfStops { get; set; } + + public string Currency { get; set; } + + public decimal Cost { get; set; } + + public Guid Uuid { get; set; } + + public short Order { get; set; } + + public ICollection + Addresses { get; set; } +} diff --git a/src/Application/VehicleEnrollmentSearch/ViewModels/SearchShortestViewModel.cs b/src/Application/VehicleEnrollmentSearch/ViewModels/SearchShortestViewModel.cs new file mode 100644 index 0000000..aa9813a --- /dev/null +++ b/src/Application/VehicleEnrollmentSearch/ViewModels/SearchShortestViewModel.cs @@ -0,0 +1,16 @@ +namespace cuqmbr.TravelGuide.Application.VehicleEnrollmentSearch.ViewModels; + +public sealed class SearchShortestViewModel +{ + public Guid DepartureAddressUuid { get; set; } + + public Guid ArrivalAddressUuid { get; set; } + + public DateOnly DepartureDate { get; set; } + + public HashSet VehicleTypes { get; set; } + + public bool ShortestByCost { get; set; } + + public bool ShortestByTime { get; set; } +} diff --git a/src/Application/packages.lock.json b/src/Application/packages.lock.json index f56b518..ac19a92 100644 --- a/src/Application/packages.lock.json +++ b/src/Application/packages.lock.json @@ -59,6 +59,12 @@ "Microsoft.Extensions.Options": "9.0.4" } }, + "QuikGraph": { + "type": "Direct", + "requested": "[2.5.0, )", + "resolved": "2.5.0", + "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" + }, "System.Linq.Dynamic.Core": { "type": "Direct", "requested": "[1.6.2, )", diff --git a/src/Configuration/packages.lock.json b/src/Configuration/packages.lock.json index 47118f9..8fb4534 100644 --- a/src/Configuration/packages.lock.json +++ b/src/Configuration/packages.lock.json @@ -742,6 +742,11 @@ "Npgsql": "9.0.3" } }, + "QuikGraph": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.10", @@ -838,6 +843,7 @@ "MediatR": "[12.4.1, )", "MediatR.Behaviors.Authorization": "[12.2.0, )", "Microsoft.Extensions.Logging": "[9.0.4, )", + "QuikGraph": "[2.5.0, )", "System.Linq.Dynamic.Core": "[1.6.2, )" } }, diff --git a/src/Domain/Entities/VehicleEnrollment.cs b/src/Domain/Entities/VehicleEnrollment.cs index 31ae842..b6c2ca0 100644 --- a/src/Domain/Entities/VehicleEnrollment.cs +++ b/src/Domain/Entities/VehicleEnrollment.cs @@ -23,4 +23,197 @@ public class VehicleEnrollment : EntityBase public ICollection Tickets { get; set; } + + + public DateTimeOffset GetDepartureTime(long DepartureRouteAddressId) + { + var firstRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).First().RouteAddressId; + + if (DepartureRouteAddressId == firstRouteAddressId) + { + return DepartureTime; + } + + + var orderedRouteAddressDetails = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order); + + var timeToDeparture = TimeSpan.Zero; + foreach (var routeAddressDetail in orderedRouteAddressDetails) + { + timeToDeparture = + timeToDeparture + routeAddressDetail.CurrentAddressStopTime; + + if (routeAddressDetail.Id == DepartureRouteAddressId) + { + break; + } + + timeToDeparture = + timeToDeparture += routeAddressDetail.TimeToNextAddress; + } + + return DepartureTime + timeToDeparture; + } + + public DateTimeOffset GetDepartureTime() + { + var firstRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).First().RouteAddressId; + + return GetDepartureTime(firstRouteAddressId); + } + + public DateTimeOffset GetArrivalTime(long ArrivalRouteAddressId) + { + var orderedRouteAddressDetails = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order); + + var timeToDeparture = TimeSpan.Zero; + foreach (var routeAddressDetail in orderedRouteAddressDetails) + { + if (routeAddressDetail.Id == ArrivalRouteAddressId) + { + break; + } + + timeToDeparture = + timeToDeparture + + routeAddressDetail.TimeToNextAddress + + routeAddressDetail.CurrentAddressStopTime; + } + + return DepartureTime + timeToDeparture; + } + + public DateTimeOffset GetArrivalTime() + { + var lastRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).Last().RouteAddressId; + + return GetArrivalTime(lastRouteAddressId); + } + + public TimeSpan GetTravelTime( + long DepartureRouteAddressId, long ArrivalRouteAddressId) + { + return + GetArrivalTime(ArrivalRouteAddressId) - + GetDepartureTime(DepartureRouteAddressId); + } + + public TimeSpan GetTravelTime() + { + var firstRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).First().RouteAddressId; + var lastRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).Last().RouteAddressId; + + return GetTravelTime(firstRouteAddressId, lastRouteAddressId); + } + + public TimeSpan GetTimeInStops( + long DepartureRouteAddressId, long ArrivalRouteAddressId) + { + var orderedRouteAddressDetails = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order); + + var departureRouteAddressDetail = orderedRouteAddressDetails + .Single(e => e.Id == DepartureRouteAddressId); + + var timeInStops = TimeSpan.Zero; + foreach (var routeAddressDetail in orderedRouteAddressDetails) + { + if (routeAddressDetail.RouteAddress.Order <= + departureRouteAddressDetail.RouteAddress.Order) + { + continue; + } + + if (routeAddressDetail.Id == ArrivalRouteAddressId) + { + break; + } + + timeInStops += routeAddressDetail.CurrentAddressStopTime; + } + + return timeInStops; + } + + public TimeSpan GetTimeInStops() + { + var firstRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).First().RouteAddressId; + var lastRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).Last().RouteAddressId; + + return GetTimeInStops(firstRouteAddressId, lastRouteAddressId); + } + + public int GetNumberOfStops( + long DepartureRouteAddressId, long ArrivalRouteAddressId) + { + return + RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order) + .SkipWhile(e => e.Id != DepartureRouteAddressId) + .TakeWhile(e => e.Id != ArrivalRouteAddressId) + .Count() - 1; + } + + public TimeSpan GetNumberOfStops() + { + var firstRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).First().RouteAddressId; + var lastRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).Last().RouteAddressId; + + return GetTimeInStops(firstRouteAddressId, lastRouteAddressId); + } + + public TimeSpan GetTimeMoving( + long DepartureRouteAddressId, long ArrivalRouteAddressId) + { + return + RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order) + .SkipWhile(e => e.Id != DepartureRouteAddressId) + .TakeWhile(e => e.Id != ArrivalRouteAddressId) + .Aggregate(TimeSpan.Zero, + (sum, next) => sum += next.TimeToNextAddress); + } + + public TimeSpan GetTimeMoving() + { + var firstRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).First().RouteAddressId; + var lastRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).Last().RouteAddressId; + + return GetTimeMoving(firstRouteAddressId, lastRouteAddressId); + } + + public decimal GetCost( + long DepartureRouteAddressId, long ArrivalRouteAddressId) + { + return + RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order) + .SkipWhile(e => e.Id != DepartureRouteAddressId) + .TakeWhile(e => e.Id != ArrivalRouteAddressId) + .Aggregate((decimal)0, + (sum, next) => sum += next.CostToNextAddress); + } + + public decimal GetCost() + { + var firstRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).First().RouteAddressId; + var lastRouteAddressId = RouteAddressDetails + .OrderBy(e => e.RouteAddress.Order).Last().RouteAddressId; + + return GetCost(firstRouteAddressId, lastRouteAddressId); + } } diff --git a/src/HttpApi/Controllers/VehicleEnrollmentSearchController.cs b/src/HttpApi/Controllers/VehicleEnrollmentSearchController.cs new file mode 100644 index 0000000..e10020e --- /dev/null +++ b/src/HttpApi/Controllers/VehicleEnrollmentSearchController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using cuqmbr.TravelGuide.Domain.Enums; +using cuqmbr.TravelGuide.Application.VehicleEnrollmentSearch; +using cuqmbr.TravelGuide.Application.VehicleEnrollmentSearch + .Queries.SearchShortest; +using cuqmbr.TravelGuide.Application.VehicleEnrollmentSearch.ViewModels; + +namespace cuqmbr.TravelGuide.HttpApi.Controllers; + +[Route("vehicleEnrollmentSearch")] +public class VehicleEnrollmentSearchController : ControllerBase +{ + [HttpGet] + [SwaggerOperation("Search vehicle enrollments with transfers")] + [SwaggerResponse( + StatusCodes.Status200OK, "Search successful", + typeof(VehicleEnrollmentSearchDto))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "No enrollments found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task> Add( + [FromQuery] SearchShortestViewModel viewModel, + CancellationToken cancellationToken) + { + return StatusCode( + StatusCodes.Status201Created, + await Mediator.Send( + new SearchShortestQuery() + { + DepartureAddressGuid = viewModel.DepartureAddressUuid, + ArrivalAddressGuid = viewModel.ArrivalAddressUuid, + DepartureDate = viewModel.DepartureDate, + VehicleTypes = viewModel.VehicleTypes + .Select(e => VehicleType.FromName(e)).ToHashSet(), + ShortestByCost = viewModel.ShortestByCost, + ShortestByTime = viewModel.ShortestByTime + }, + cancellationToken)); + } +} diff --git a/src/HttpApi/packages.lock.json b/src/HttpApi/packages.lock.json index a8c38fc..58c6c3e 100644 --- a/src/HttpApi/packages.lock.json +++ b/src/HttpApi/packages.lock.json @@ -897,6 +897,11 @@ "Npgsql": "9.0.3" } }, + "QuikGraph": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.10", @@ -1079,6 +1084,7 @@ "MediatR": "[12.4.1, )", "MediatR.Behaviors.Authorization": "[12.2.0, )", "Microsoft.Extensions.Logging": "[9.0.4, )", + "QuikGraph": "[2.5.0, )", "System.Linq.Dynamic.Core": "[1.6.2, )" } }, diff --git a/src/Identity/packages.lock.json b/src/Identity/packages.lock.json index aa4f436..22ffc53 100644 --- a/src/Identity/packages.lock.json +++ b/src/Identity/packages.lock.json @@ -528,6 +528,11 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.2" } }, + "QuikGraph": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" + }, "System.Buffers": { "type": "Transitive", "resolved": "4.6.0", @@ -589,6 +594,7 @@ "MediatR": "[12.4.1, )", "MediatR.Behaviors.Authorization": "[12.2.0, )", "Microsoft.Extensions.Logging": "[9.0.4, )", + "QuikGraph": "[2.5.0, )", "System.Linq.Dynamic.Core": "[1.6.2, )" } }, diff --git a/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs b/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs index 15d444d..ee4d9d0 100644 --- a/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs +++ b/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs @@ -41,7 +41,7 @@ public sealed class ExchangeApiCurrencyConverterService : public async Task ConvertAsync(decimal amount, Currency from, Currency to, DateTimeOffset time, CancellationToken cancellationToken) { - if (from.Equals(to)) + if (from.Equals(to) || to.Equals(Currency.Default)) { return amount; } diff --git a/src/Infrastructure/packages.lock.json b/src/Infrastructure/packages.lock.json index b89053f..f67a272 100644 --- a/src/Infrastructure/packages.lock.json +++ b/src/Infrastructure/packages.lock.json @@ -234,6 +234,11 @@ "resolved": "9.0.4", "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, + "QuikGraph": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" + }, "System.Linq.Dynamic.Core": { "type": "Transitive", "resolved": "1.6.2", @@ -249,6 +254,7 @@ "MediatR": "[12.4.1, )", "MediatR.Behaviors.Authorization": "[12.2.0, )", "Microsoft.Extensions.Logging": "[9.0.4, )", + "QuikGraph": "[2.5.0, )", "System.Linq.Dynamic.Core": "[1.6.2, )" } }, diff --git a/src/Persistence/packages.lock.json b/src/Persistence/packages.lock.json index af85452..3cb01b7 100644 --- a/src/Persistence/packages.lock.json +++ b/src/Persistence/packages.lock.json @@ -274,6 +274,11 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.2" } }, + "QuikGraph": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.10", @@ -329,6 +334,7 @@ "MediatR": "[12.4.1, )", "MediatR.Behaviors.Authorization": "[12.2.0, )", "Microsoft.Extensions.Logging": "[9.0.4, )", + "QuikGraph": "[2.5.0, )", "System.Linq.Dynamic.Core": "[1.6.2, )" } }, diff --git a/tst/Application.IntegrationTests/packages.lock.json b/tst/Application.IntegrationTests/packages.lock.json index 4fd295d..4967b9d 100644 --- a/tst/Application.IntegrationTests/packages.lock.json +++ b/tst/Application.IntegrationTests/packages.lock.json @@ -816,6 +816,11 @@ "Npgsql": "9.0.3" } }, + "QuikGraph": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.10", @@ -982,6 +987,7 @@ "MediatR": "[12.4.1, )", "MediatR.Behaviors.Authorization": "[12.2.0, )", "Microsoft.Extensions.Logging": "[9.0.4, )", + "QuikGraph": "[2.5.0, )", "System.Linq.Dynamic.Core": "[1.6.2, )" } },