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, )"
}
},