add vehicle enrollments management

This commit is contained in:
cuqmbr 2025-05-11 10:51:19 +03:00
parent 3ebd0c3a2c
commit 5ee8c9c5df
Signed by: cuqmbr
GPG Key ID: 0AA446880C766199
64 changed files with 2976 additions and 22 deletions

View File

@ -0,0 +1,7 @@
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Common.Interfaces
.Persistence.Repositories;
public interface RouteAddressRepository :
BaseRepository<RouteAddress> { }

View File

@ -0,0 +1,7 @@
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Common.Interfaces
.Persistence.Repositories;
public interface VehicleEnrollmentRepository :
BaseRepository<VehicleEnrollment> { }

View File

@ -22,6 +22,10 @@ public interface UnitOfWork : IDisposable
TrainRepository TrainRepository { get; } TrainRepository TrainRepository { get; }
VehicleEnrollmentRepository VehicleEnrollmentRepository { get; }
RouteAddressRepository RouteAddressRepository { get; }
int Save(); int Save();
Task<int> SaveAsync(CancellationToken cancellationToken); Task<int> SaveAsync(CancellationToken cancellationToken);

View File

@ -8,7 +8,15 @@
}, },
"Validation": { "Validation": {
"DistinctOrder": "Must have distinct order values.", "DistinctOrder": "Must have distinct order values.",
"SameVehicleType": "Must have the same vehicle type." "SameVehicleType": "Must have the same vehicle type.",
"DateTimeOffset": {
"GreaterThanOrEqualTo": "Must be greater or equal to {0:U}"
},
"VehicleEnrollments": {
"OverlapWithOther": "Provided vehicle enrollment overlapping in schedule with other one.",
"NegativeTime": "Specified time must be positive time span.",
"NegativeCost": "Specified cost must be positive value."
}
}, },
"ExceptionHandling": { "ExceptionHandling": {
"ValidationException": { "ValidationException": {

View File

@ -5,18 +5,20 @@ namespace cuqmbr.TravelGuide.Application.Routes;
public sealed class RouteAddressDto : IMapFrom<RouteAddress> public sealed class RouteAddressDto : IMapFrom<RouteAddress>
{ {
public Guid RouteAddressUuid { get; set; }
public short Order { get; set; } public short Order { get; set; }
public Guid Uuid { get; set; } public Guid AddressUuid { get; set; }
public string Name { get; set; } public string AddressName { get; set; }
public double Longitude { get; set; } public double AddressLongitude { get; set; }
public double Latitude { get; set; } public double AddressLatitude { get; set; }
public string VehicleType { get; set; } public string AddressVehicleType { get; set; }
public Guid CountryUuid { get; set; } public Guid CountryUuid { get; set; }
@ -34,19 +36,22 @@ public sealed class RouteAddressDto : IMapFrom<RouteAddress>
{ {
profile.CreateMap<RouteAddress, RouteAddressDto>() profile.CreateMap<RouteAddress, RouteAddressDto>()
.ForMember( .ForMember(
d => d.Uuid, d => d.RouteAddressUuid,
opt => opt.MapFrom(s => s.Guid))
.ForMember(
d => d.AddressUuid,
opt => opt.MapFrom(s => s.Address.Guid)) opt => opt.MapFrom(s => s.Address.Guid))
.ForMember( .ForMember(
d => d.Name, d => d.AddressName,
opt => opt.MapFrom(s => s.Address.Name)) opt => opt.MapFrom(s => s.Address.Name))
.ForMember( .ForMember(
d => d.Longitude, d => d.AddressLongitude,
opt => opt.MapFrom(s => s.Address.Longitude)) opt => opt.MapFrom(s => s.Address.Longitude))
.ForMember( .ForMember(
d => d.Latitude, d => d.AddressLatitude,
opt => opt.MapFrom(s => s.Address.Latitude)) opt => opt.MapFrom(s => s.Address.Latitude))
.ForMember( .ForMember(
d => d.VehicleType, d => d.AddressVehicleType,
opt => opt.MapFrom(s => s.Address.VehicleType.Name)) opt => opt.MapFrom(s => s.Address.VehicleType.Name))
.ForMember( .ForMember(
d => d.CityUuid, d => d.CityUuid,

View File

@ -0,0 +1,20 @@
using cuqmbr.TravelGuide.Application.VehicleEnrollments.Models;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.AddVehicleEnrollment;
public record AddVehicleEnrollmentCommand : IRequest<VehicleEnrollmentDto>
{
public DateTimeOffset DepartureTime { get; set; }
public Currency Currency { get; set; }
public Guid VehicleGuid { get; set; }
public Guid RouteGuid { get; set; }
public ICollection<RouteAddressDetailModel> RouteAddressDetails { get; set; }
}

View File

@ -0,0 +1,32 @@
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.VehicleEnrollments
.Commands.AddVehicleEnrollment;
public class AddVehicleEnrollmentCommandAuthorizer :
AbstractRequestAuthorizer<AddVehicleEnrollmentCommand>
{
private readonly SessionUserService _sessionUserService;
public AddVehicleEnrollmentCommandAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(AddVehicleEnrollmentCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,214 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using cuqmbr.TravelGuide.Domain.Entities;
using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using FluentValidation.Results;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.AddVehicleEnrollment;
public class AddVehicleEnrollmentCommandHandler :
IRequestHandler<AddVehicleEnrollmentCommand, VehicleEnrollmentDto>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly IStringLocalizer _localizer;
public AddVehicleEnrollmentCommandHandler(
UnitOfWork unitOfWork,
IMapper mapper,
IStringLocalizer localizer)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_localizer = localizer;
}
public async Task<VehicleEnrollmentDto> Handle(
AddVehicleEnrollmentCommand request,
CancellationToken cancellationToken)
{
// Check if the vehicle exists.
var vehicle = await _unitOfWork.VehicleRepository.GetOneAsync(
e => e.Guid == request.VehicleGuid, cancellationToken);
if (vehicle == null)
{
throw new NotFoundException(
$"Vehicle with Guid: {request.VehicleGuid} not found.");
}
// Check if the route exists.
var route = await _unitOfWork.RouteRepository.GetOneAsync(
e => e.Guid == request.RouteGuid, e => e.RouteAddresses,
cancellationToken);
if (route == null)
{
throw new NotFoundException(
$"Route with Guid: {request.RouteGuid} not found.");
}
// Check if specified vehicle and route compatible.
if (vehicle.VehicleType != route.VehicleType)
{
throw new ValidationException(
new List<ValidationFailure>
{
new()
{
PropertyName = nameof(request.VehicleGuid),
ErrorMessage = _localizer["Validation.SameVehicleType"]
}
});
}
// Check that request has the same Route Addresses
// as a route data from datastore.
var sameRouteAddresses = route.RouteAddresses.All(
ra => request.RouteAddressDetails.Any(
rad => rad.RouteAddressGuid == ra.Guid));
if (!sameRouteAddresses)
{
throw new NotFoundException(
$"Not all route addresses are found in a datastore.");
}
// Check vehicle enrollments that might overlap with new one.
var requestDepartureTime = request.DepartureTime;
var requestTravelTime =
request.RouteAddressDetails.Aggregate(
TimeSpan.Zero, (sum, rad) => sum +
rad.TimeToNextAddress + rad.CurrentAddressStopTime);
var requestArrivalTime = requestDepartureTime + requestTravelTime;
var enrollmentHistory =
await _unitOfWork.VehicleEnrollmentRepository.GetPageAsync(
e =>
e.Vehicle.Guid == request.VehicleGuid &&
e.DepartureTime >= DateTimeOffset.UtcNow.AddDays(-7),
e => e.RouteAddressDetails,
1, 200, cancellationToken);
// Three cases are included:
//
// ---RD---------SD----------RA--->
// time
//
// ---RD---------SA----------RA--->
// time
//
// ---SD-----RD-------RA-----SA--->
// time
// Where:
// RD - request enrollment departure time
// RA - request enrollment arrival time
// SD - datastore enrollment (S for store) departure time
// SA - datastore enrollment (S for store) arrival time
var overlappingWithOtherEnrollments = enrollmentHistory.Items
.Where(ve =>
{
var departureTime = ve.DepartureTime;
var arrivalTime =
ve.DepartureTime +
ve.RouteAddressDetails
.Aggregate(
TimeSpan.Zero,
(sum, rad) => sum +
rad.TimeToNextAddress +
rad.CurrentAddressStopTime);
return
(departureTime >= requestDepartureTime &&
departureTime <= requestArrivalTime) ||
(arrivalTime >= requestDepartureTime &&
arrivalTime <= requestArrivalTime) ||
(departureTime <= requestDepartureTime &&
arrivalTime >= requestArrivalTime);
})
.Any();
if (overlappingWithOtherEnrollments)
{
throw new ValidationException(
new List<ValidationFailure>
{
new()
{
PropertyName = nameof(request.DepartureTime),
ErrorMessage = _localizer["Validation." +
"VehicleEnrollments.OverlapWithOther"]
}
});
}
// Create entity and add to datastore.
var entity = new VehicleEnrollment()
{
DepartureTime = request.DepartureTime,
Currency = request.Currency,
VehicleId = vehicle.Id,
RouteId = route.Id,
RouteAddressDetails = route.RouteAddresses
.OrderBy(ra => ra.Order)
.Select(ra => new RouteAddressDetail()
{
TimeToNextAddress = request.RouteAddressDetails
.First(rad => rad.RouteAddressGuid == ra.Guid)
.TimeToNextAddress,
CostToNextAddress = request.RouteAddressDetails
.First(rad => rad.RouteAddressGuid == ra.Guid)
.CostToNextAddress,
CurrentAddressStopTime = request.RouteAddressDetails
.First(rad => rad.RouteAddressGuid == ra.Guid)
.CurrentAddressStopTime,
RouteAddressId = ra.Id
})
.ToArray()
};
entity = await _unitOfWork.VehicleEnrollmentRepository.AddOneAsync(
entity, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
// Hydrate vehicle enrollment with address information
var routeAddresses = await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e =>
request.RouteAddressDetails
.Select(rad => rad.RouteAddressGuid)
.Contains(e.Guid),
e => e.Address.City.Region.Country,
1, entity.RouteAddressDetails.Count(),
cancellationToken);
foreach (var rad in entity.RouteAddressDetails)
{
rad.RouteAddress = routeAddresses.Items
.First(ra => ra.Id == rad.RouteAddressId);
}
_unitOfWork.Dispose();
return _mapper.Map<VehicleEnrollmentDto>(entity);
}
}

View File

@ -0,0 +1,63 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.AddVehicleEnrollment;
public class AddVehicleEnrollmentCommandValidator :
AbstractValidator<AddVehicleEnrollmentCommand>
{
public AddVehicleEnrollmentCommandValidator(
IStringLocalizer localizer,
CultureService cultureService,
TimeZoneService timeZoneService)
{
RuleFor(v => v.DepartureTime)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.Must(dt => dt >= DateTimeOffset.Now)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.DateTimeOffset.GreaterThanOrEqualTo"],
DateTimeOffset.Now.ToOffset(timeZoneService.TimeZone.BaseUtcOffset)));
RuleFor(v => v.Currency)
.Must(c => Currency.Enumerations.ContainsValue(c))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
Currency.Enumerations.Values.Select(e => e.Name))));
RuleFor(v => v.RouteAddressDetails.Count)
.GreaterThanOrEqualTo(2)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
2));
RuleFor(v => v.RouteAddressDetails)
.Must(v => v.All(rad => rad.RouteAddressGuid != Guid.Empty))
.WithMessage(localizer["FluentValidation.NotEmpty"])
.Must(v => v.All(rad => rad.TimeToNextAddress >= TimeSpan.Zero))
.WithMessage(localizer["VehicleEnrollments.NegativeTime"])
.Must(v => v.All(rad => rad.CostToNextAddress >= 0))
.WithMessage(localizer["VehicleEnrollments.NegativeCost"])
.Must(v => v.All(rad => rad.CurrentAddressStopTime >= TimeSpan.Zero))
.WithMessage(localizer["VehicleEnrollments.NegativeTime"]);
RuleFor(v => v.VehicleGuid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
RuleFor(v => v.RouteGuid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Commands.DeleteVehicleEnrollment;
public record DeleteVehicleEnrollmentCommand : IRequest
{
public Guid Guid { get; set; }
}

View File

@ -0,0 +1,31 @@
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.VehicleEnrollments.Commands.DeleteVehicleEnrollment;
public class DeleteVehicleEnrollmentCommandAuthorizer :
AbstractRequestAuthorizer<DeleteVehicleEnrollmentCommand>
{
private readonly SessionUserService _sessionUserService;
public DeleteVehicleEnrollmentCommandAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(DeleteVehicleEnrollmentCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,38 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Commands.DeleteVehicleEnrollment;
public class DeleteVehicleEnrollmentCommandHandler :
IRequestHandler<DeleteVehicleEnrollmentCommand>
{
private readonly UnitOfWork _unitOfWork;
public DeleteVehicleEnrollmentCommandHandler(UnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task Handle(
DeleteVehicleEnrollmentCommand request,
CancellationToken cancellationToken)
{
var entity = await _unitOfWork.VehicleEnrollmentRepository.GetOneAsync(
e => e.Guid == request.Guid, cancellationToken);
if (entity == null)
{
throw new NotFoundException();
}
// TODO: Check for tickets bought for this enrollment.
// Decide whether to cancel tickets or do not allow deletion.
await _unitOfWork.VehicleEnrollmentRepository.DeleteOneAsync(
entity, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose();
}
}

View File

@ -0,0 +1,14 @@
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Commands.DeleteVehicleEnrollment;
public class DeleteVehicleEnrollmentCommandValidator : AbstractValidator<DeleteVehicleEnrollmentCommand>
{
public DeleteVehicleEnrollmentCommandValidator(IStringLocalizer localizer)
{
RuleFor(v => v.Guid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
}
}

View File

@ -0,0 +1,18 @@
using cuqmbr.TravelGuide.Application.VehicleEnrollments.Models;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.UpdateVehicleEnrollment;
public record UpdateVehicleEnrollmentCommand : IRequest<VehicleEnrollmentDto>
{
public Guid Guid { get; set; }
public DateTimeOffset DepartureTime { get; set; }
public Currency Currency { get; set; }
public ICollection<RouteAddressDetailModel> RouteAddressDetails { get; set; }
}

View File

@ -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.VehicleEnrollments
.Commands.UpdateVehicleEnrollment;
public class UpdateVehicleEnrollmentCommandAuthorizer :
AbstractRequestAuthorizer<UpdateVehicleEnrollmentCommand>
{
private readonly SessionUserService _sessionUserService;
public UpdateVehicleEnrollmentCommandAuthorizer(
SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(UpdateVehicleEnrollmentCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,191 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using FluentValidation.Results;
using Microsoft.Extensions.Localization;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.UpdateVehicleEnrollment;
public class UpdateVehicleEnrollmentCommandHandler :
IRequestHandler<UpdateVehicleEnrollmentCommand, VehicleEnrollmentDto>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly IStringLocalizer _localizer;
public UpdateVehicleEnrollmentCommandHandler(
UnitOfWork unitOfWork,
IMapper mapper,
IStringLocalizer localizer)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_localizer = localizer;
}
public async Task<VehicleEnrollmentDto> Handle(
UpdateVehicleEnrollmentCommand request,
CancellationToken cancellationToken)
{
// TODO: Check for tickets bought for this enrollment.
// Decide whether allow or not to perform update action.
var entity = await _unitOfWork.VehicleEnrollmentRepository.GetOneAsync(
e => e.Guid == request.Guid, e => e.RouteAddressDetails,
cancellationToken);
if (entity == null)
{
throw new NotFoundException();
}
// Check that request has the same Route Addresses
// as a route data from datastore.
var route = await _unitOfWork.RouteRepository.GetOneAsync(
e => e.Id == entity.RouteId, e => e.RouteAddresses,
cancellationToken);
var sameRouteAddresses = route.RouteAddresses.All(
ra => request.RouteAddressDetails.Any(
rad => rad.RouteAddressGuid == ra.Guid));
if (!sameRouteAddresses)
{
throw new NotFoundException(
$"Not all route addresses are found in a datastore.");
}
// Check vehicle enrollments that might overlap with updated one.
// Exclude this vehicle enrollment.
var requestDepartureTime = request.DepartureTime;
var requestTravelTime =
request.RouteAddressDetails.Aggregate(
TimeSpan.Zero, (sum, rad) => sum +
rad.TimeToNextAddress + rad.CurrentAddressStopTime);
var requestArrivalTime = requestDepartureTime + requestTravelTime;
var enrollmentHistory =
await _unitOfWork.VehicleEnrollmentRepository.GetPageAsync(
e =>
e.Vehicle.Id == entity.VehicleId &&
e.Id != entity.Id &&
e.DepartureTime >= DateTimeOffset.UtcNow.AddDays(-7),
e => e.RouteAddressDetails,
1, 200, cancellationToken);
// Three cases are included:
//
// ---RD---------SD----------RA--->
// time
//
// ---RD---------SA----------RA--->
// time
//
// ---SD-----RD-------RA-----SA--->
// time
// Where:
// RD - request enrollment departure time
// RA - request enrollment arrival time
// SD - datastore enrollment (S for store) departure time
// SA - datastore enrollment (S for store) arrival time
var overlappingWithOtherEnrollments = enrollmentHistory.Items
.Where(ve =>
{
var departureTime = ve.DepartureTime;
var arrivalTime =
ve.DepartureTime +
ve.RouteAddressDetails
.Aggregate(
TimeSpan.Zero,
(sum, rad) => sum +
rad.TimeToNextAddress +
rad.CurrentAddressStopTime);
return
(departureTime >= requestDepartureTime &&
departureTime <= requestArrivalTime) ||
(arrivalTime >= requestDepartureTime &&
arrivalTime <= requestArrivalTime) ||
(departureTime <= requestDepartureTime &&
arrivalTime >= requestArrivalTime);
})
.Any();
if (overlappingWithOtherEnrollments)
{
throw new ValidationException(
new List<ValidationFailure>
{
new()
{
PropertyName = nameof(request.DepartureTime),
ErrorMessage = _localizer["Validation." +
"VehicleEnrollments.OverlapWithOther"]
}
});
}
// Update entity and add to datastore.
entity.DepartureTime = request.DepartureTime;
entity.Currency = request.Currency;
foreach (var rad in entity.RouteAddressDetails)
{
var correspondingRouteAddress = route.RouteAddresses
.First(ra => ra.Id == rad.RouteAddressId);
rad.TimeToNextAddress = request.RouteAddressDetails
.First(rrad => rrad.RouteAddressGuid == rad.RouteAddress.Guid)
.TimeToNextAddress;
rad.CostToNextAddress = request.RouteAddressDetails
.First(rrad => rrad.RouteAddressGuid == rad.RouteAddress.Guid)
.CostToNextAddress;
rad.CurrentAddressStopTime = request.RouteAddressDetails
.First(rrad => rrad.RouteAddressGuid == rad.RouteAddress.Guid)
.CurrentAddressStopTime;
}
entity = await _unitOfWork.VehicleEnrollmentRepository.UpdateOneAsync(
entity, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
// Hydrate vehicle enrollment with address information
var routeAddresses = await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e =>
entity.RouteAddressDetails
.Select(rad => rad.RouteAddressId)
.Contains(e.Id),
e => e.Address.City.Region.Country,
1, entity.RouteAddressDetails.Count(),
cancellationToken);
foreach (var rad in entity.RouteAddressDetails)
{
rad.RouteAddress = routeAddresses.Items
.First(ra => ra.Id == rad.RouteAddressId);
}
entity.RouteAddressDetails = entity.RouteAddressDetails
.OrderBy(rad => rad.RouteAddress.Order)
.ToArray();
_unitOfWork.Dispose();
return _mapper.Map<VehicleEnrollmentDto>(entity);
}
}

View File

@ -0,0 +1,58 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.UpdateVehicleEnrollment;
public class UpdateVehicleEnrollmentCommandValidator :
AbstractValidator<UpdateVehicleEnrollmentCommand>
{
public UpdateVehicleEnrollmentCommandValidator(
IStringLocalizer localizer,
CultureService cultureService,
TimeZoneService timeZoneService)
{
RuleFor(v => v.Guid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
RuleFor(v => v.DepartureTime)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.Must(dt => dt >= DateTimeOffset.Now)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.DateTimeOffset.GreaterThanOrEqualTo"],
DateTimeOffset.Now.ToOffset(timeZoneService.TimeZone.BaseUtcOffset)));
RuleFor(v => v.Currency)
.Must(c => Currency.Enumerations.ContainsValue(c))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
Currency.Enumerations.Values.Select(e => e.Name))));
RuleFor(v => v.RouteAddressDetails.Count)
.GreaterThanOrEqualTo(2)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
2));
RuleFor(v => v.RouteAddressDetails)
.Must(v => v.All(rad => rad.RouteAddressGuid != Guid.Empty))
.WithMessage(localizer["FluentValidation.NotEmpty"])
.Must(v => v.All(rad => rad.TimeToNextAddress >= TimeSpan.Zero))
.WithMessage(localizer["VehicleEnrollments.NegativeTime"])
.Must(v => v.All(rad => rad.CostToNextAddress >= 0))
.WithMessage(localizer["VehicleEnrollments.NegativeCost"])
.Must(v => v.All(rad => rad.CurrentAddressStopTime >= TimeSpan.Zero))
.WithMessage(localizer["VehicleEnrollments.NegativeTime"]);
}
}

View File

@ -0,0 +1,13 @@
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Models;
public sealed class RouteAddressDetailModel
{
public TimeSpan TimeToNextAddress { get; set; }
public decimal CostToNextAddress { get; set; }
public TimeSpan CurrentAddressStopTime { get; set; }
public Guid RouteAddressGuid { get; set; }
}

View File

@ -0,0 +1,9 @@
using MediatR;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Queries.GetVehicleEnrollment;
public record GetVehicleEnrollmentQuery : IRequest<VehicleEnrollmentDto>
{
public Guid Guid { get; set; }
}

View File

@ -0,0 +1,32 @@
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.VehicleEnrollments
.Queries.GetVehicleEnrollment;
public class GetVehicleEnrollmentQueryAuthorizer :
AbstractRequestAuthorizer<GetVehicleEnrollmentQuery>
{
private readonly SessionUserService _sessionUserService;
public GetVehicleEnrollmentQueryAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetVehicleEnrollmentQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,58 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using AutoMapper;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Queries.GetVehicleEnrollment;
public class GetVehicleEnrollmentQueryHandler :
IRequestHandler<GetVehicleEnrollmentQuery, VehicleEnrollmentDto>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public GetVehicleEnrollmentQueryHandler(
UnitOfWork unitOfWork,
IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<VehicleEnrollmentDto> Handle(
GetVehicleEnrollmentQuery request,
CancellationToken cancellationToken)
{
var entity = await _unitOfWork.VehicleEnrollmentRepository.GetOneAsync(
e => e.Guid == request.Guid, e => e.RouteAddressDetails,
cancellationToken);
if (entity == null)
{
throw new NotFoundException();
}
// Hydrate vehicle enrollment with address information
var routeAddresses = await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e =>
entity.RouteAddressDetails
.Select(rad => rad.RouteAddressId)
.Contains(e.Id),
e => e.Address.City.Region.Country,
1, entity.RouteAddressDetails.Count(),
cancellationToken);
foreach (var rad in entity.RouteAddressDetails)
{
rad.RouteAddress = routeAddresses.Items
.First(ra => ra.Id == rad.RouteAddressId);
}
_unitOfWork.Dispose();
return _mapper.Map<VehicleEnrollmentDto>(entity);
}
}

View File

@ -0,0 +1,14 @@
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Queries.GetVehicleEnrollment;
public class GetVehicleEnrollmentQueryValidator : AbstractValidator<GetVehicleEnrollmentQuery>
{
public GetVehicleEnrollmentQueryValidator(IStringLocalizer localizer)
{
RuleFor(v => v.Guid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
}
}

View File

@ -0,0 +1,52 @@
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Queries.GetVehicleEnrollmentsPage;
public record GetVehicleEnrollmentsPageQuery :
IRequest<PaginatedList<VehicleEnrollmentDto>>
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
// public string Search { get; set; } = String.Empty;
public string Sort { get; set; } = String.Empty;
public Guid? RouteGuid { get; set; }
public Guid? VehicleGuid { get; set; }
public int? NumberOfAddressesGreaterThanOrEqual { get; set; }
public int? NumberOfAddressesLessThanOrEqual { get; set; }
public DateTimeOffset? DepartureTimeGreaterThanOrEqual { get; set; }
public DateTimeOffset? DepartureTimeLessThanOrEqual { get; set; }
public DateTimeOffset? ArrivalTimeGreaterThanOrEqual { get; set; }
public DateTimeOffset? ArrivalTimeLessThanOrEqual { get; set; }
public TimeSpan? TravelTimeGreaterThanOrEqual { get; set; }
public TimeSpan? TravelTimeLessThanOrEqual { get; set; }
public TimeSpan? TimeMovingGreaterThanOrEqual { get; set; }
public TimeSpan? TimeMovingLessThanOrEqual { get; set; }
public TimeSpan? TimeInStopsGreaterThanOrEqual { get; set; }
public TimeSpan? TimeInStopsLessThanOrEqual { get; set; }
public decimal? CostGreaterThanOrEqual { get; set; }
public decimal? CostLessThanOrEqual { get; set; }
public Currency? Currency { get; set; }
}

View File

@ -0,0 +1,31 @@
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.VehicleEnrollments.Queries.GetVehicleEnrollmentsPage;
public class GetVehicleEnrollmentsPageQueryAuthorizer :
AbstractRequestAuthorizer<GetVehicleEnrollmentsPageQuery>
{
private readonly SessionUserService _sessionUserService;
public GetVehicleEnrollmentsPageQueryAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetVehicleEnrollmentsPageQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,157 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Application.Common.Extensions;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments
.Queries.GetVehicleEnrollmentsPage;
public class GetVehicleEnrollmentsPageQueryHandler :
IRequestHandler<GetVehicleEnrollmentsPageQuery, PaginatedList<VehicleEnrollmentDto>>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public GetVehicleEnrollmentsPageQueryHandler(
UnitOfWork unitOfWork,
IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<PaginatedList<VehicleEnrollmentDto>> Handle(
GetVehicleEnrollmentsPageQuery request,
CancellationToken cancellationToken)
{
// TODO: Add search functionality or remove it
var paginatedList = await _unitOfWork.VehicleEnrollmentRepository.GetPageAsync(
e =>
// (e.Name.ToLower().Contains(request.Search.ToLower()) ||
// e.City.Region.Country.Name.ToLower().Contains(request.Search.ToLower())) &&
(request.RouteGuid != null
? e.Route.Guid == request.RouteGuid
: true) &&
(request.VehicleGuid != null
? e.Vehicle.Guid >= request.VehicleGuid
: true) &&
(request.NumberOfAddressesGreaterThanOrEqual != null
?
e.RouteAddressDetails.Count() >=
request.NumberOfAddressesGreaterThanOrEqual
: true) &&
(request.NumberOfAddressesLessThanOrEqual != null
?
e.RouteAddressDetails.Count() <=
request.NumberOfAddressesLessThanOrEqual
: true) &&
(request.DepartureTimeGreaterThanOrEqual != null
? e.DepartureTime >= request.DepartureTimeGreaterThanOrEqual
: true) &&
(request.DepartureTimeLessThanOrEqual != null
? e.DepartureTime <= request.DepartureTimeLessThanOrEqual
: true) &&
(request.ArrivalTimeGreaterThanOrEqual != null
?
e.DepartureTime.AddSeconds(e.RouteAddressDetails
.Sum(rad => rad.TimeToNextAddress.TotalSeconds + rad.CurrentAddressStopTime.TotalSeconds)) >=
request.ArrivalTimeGreaterThanOrEqual
: true) &&
(request.ArrivalTimeLessThanOrEqual != null
?
e.DepartureTime.AddSeconds(e.RouteAddressDetails
.Sum(rad => rad.TimeToNextAddress.TotalSeconds + rad.CurrentAddressStopTime.TotalSeconds)) <=
request.ArrivalTimeLessThanOrEqual
: true) &&
(request.TravelTimeGreaterThanOrEqual != null
?
e.RouteAddressDetails
.Sum(rad => rad.TimeToNextAddress.TotalSeconds) >=
request.TravelTimeGreaterThanOrEqual.Value.TotalSeconds
: true) &&
(request.TravelTimeLessThanOrEqual != null
?
e.RouteAddressDetails
.Sum(rad => rad.TimeToNextAddress.TotalSeconds) <=
request.TravelTimeLessThanOrEqual.Value.TotalSeconds
: true) &&
(request.TimeMovingGreaterThanOrEqual != null
?
e.RouteAddressDetails
.Sum(rad => rad.TimeToNextAddress.TotalSeconds) >=
request.TimeMovingGreaterThanOrEqual.Value.TotalSeconds
: true) &&
(request.TimeMovingLessThanOrEqual != null
?
e.RouteAddressDetails
.Sum(rad => rad.TimeToNextAddress.TotalSeconds) <=
request.TimeMovingLessThanOrEqual.Value.TotalSeconds
: true) &&
(request.TimeInStopsGreaterThanOrEqual != null
?
e.RouteAddressDetails
.Sum(rad => rad.CurrentAddressStopTime.TotalSeconds) >=
request.TimeInStopsGreaterThanOrEqual.Value.TotalSeconds
: true) &&
(request.TimeInStopsLessThanOrEqual != null
?
e.RouteAddressDetails
.Sum(rad => rad.CurrentAddressStopTime.TotalSeconds) <=
request.TimeInStopsLessThanOrEqual.Value.TotalSeconds
: true) &&
(request.CostGreaterThanOrEqual != null
? e.RouteAddressDetails.Sum(rad => rad.CostToNextAddress) >=
request.CostGreaterThanOrEqual
: true) &&
(request.CostLessThanOrEqual != null
? e.RouteAddressDetails.Sum(rad => rad.CostToNextAddress) <=
request.CostLessThanOrEqual
: true) &&
(request.Currency != null
? e.Currency == request.Currency
: true),
e => e.RouteAddressDetails,
request.PageNumber, request.PageSize,
cancellationToken);
// Hydrate vehicle enrollment with address information
var routeAddressIds = paginatedList.Items
.SelectMany(ve => ve.RouteAddressDetails)
.Select(rad => rad.RouteAddressId);
var routeAddresses = await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e =>
routeAddressIds.Contains(e.Id),
e => e.Address.City.Region.Country,
1, paginatedList.Items.Sum(e => e.RouteAddressDetails.Count()),
cancellationToken);
foreach (var vehicleEnrollment in paginatedList.Items)
{
foreach (var routeAddressDetail in
vehicleEnrollment.RouteAddressDetails)
{
routeAddressDetail.RouteAddress = routeAddresses.Items
.First(ra => ra.Id == routeAddressDetail.RouteAddressId);
}
}
var mappedItems = _mapper
.ProjectTo<VehicleEnrollmentDto>(paginatedList.Items.AsQueryable());
mappedItems = QueryableExtension<VehicleEnrollmentDto>
.ApplySort(mappedItems, request.Sort);
_unitOfWork.Dispose();
return new PaginatedList<VehicleEnrollmentDto>(
mappedItems.ToList(),
paginatedList.TotalCount, request.PageNumber,
request.PageSize);
}
}

View File

@ -0,0 +1,43 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.Queries.GetVehicleEnrollmentsPage;
public class GetVehicleEnrollmentsPageQueryValidator : AbstractValidator<GetVehicleEnrollmentsPageQuery>
{
public GetVehicleEnrollmentsPageQueryValidator(
IStringLocalizer localizer,
CultureService cultureService)
{
RuleFor(v => v.PageNumber)
.GreaterThanOrEqualTo(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
1));
RuleFor(v => v.PageSize)
.GreaterThanOrEqualTo(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
1))
.LessThanOrEqualTo(50)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.LessThanOrEqualTo"],
50));
// RuleFor(v => v.Search)
// .MaximumLength(64)
// .WithMessage(
// String.Format(
// cultureService.Culture,
// localizer["FluentValidation.MaximumLength"],
// 64));
}
}

View File

@ -0,0 +1,29 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments;
public sealed class RouteAddressDetailDto : IMapFrom<RouteAddressDetail>
{
public TimeSpan TimeToNextAddress { get; set; }
public decimal CostToNextAddress { get; set; }
public TimeSpan CurrentAddressStopTime { get; set; }
public Guid RouteAddressUuid { get; set; }
public VehicleEnrollmentRouteAddressDto RouteAddress { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<RouteAddressDetail, RouteAddressDetailDto>()
.ForMember(
d => d.RouteAddressUuid,
opt => opt.MapFrom(s => s.RouteAddress.Guid))
.ForMember(
d => d.RouteAddress,
opt => opt.MapFrom(s => s.RouteAddress));
}
}

View File

@ -0,0 +1,50 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Application.Common.Mappings.Resolvers;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments;
public sealed class VehicleEnrollmentDto : IMapFrom<VehicleEnrollment>
{
public Guid Uuid { get; set; }
public DateTimeOffset DepartureTime { get; set; }
public DateTimeOffset ArrivalTime =>
DepartureTime +
RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.TimeToNextAddress + next.CurrentAddressStopTime);
public TimeSpan TravelTime =>
RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.TimeToNextAddress + next.CurrentAddressStopTime);
public TimeSpan TimeMoving =>
RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.TimeToNextAddress);
public TimeSpan TimeInStops =>
RouteAddressDetails.Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.CurrentAddressStopTime);
public decimal TotalCost =>
RouteAddressDetails.Aggregate((decimal)0, (sum, next) =>
sum + next.CostToNextAddress);
public string Currency { get; set; }
public ICollection<RouteAddressDetailDto> RouteAddressDetails { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<VehicleEnrollment, VehicleEnrollmentDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid))
.ForMember(
d => d.DepartureTime,
opt => opt
.MapFrom<DateTimeOffsetToLocalResolver, DateTimeOffset>(
s => s.DepartureTime));
}
}

View File

@ -0,0 +1,75 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments;
public sealed class VehicleEnrollmentRouteAddressDto : IMapFrom<RouteAddress>
{
public Guid Uuid { get; set; }
public short Order { get; set; }
public Guid AddressUuid { get; set; }
public string AddressName { get; set; }
public double AddressLongitude { get; set; }
public double AddressLatitude { get; set; }
public string AddressVehicleType { 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 void Mapping(MappingProfile profile)
{
profile.CreateMap<RouteAddress, VehicleEnrollmentRouteAddressDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid))
.ForMember(
d => d.AddressUuid,
opt => opt.MapFrom(s => s.Address.Guid))
.ForMember(
d => d.AddressName,
opt => opt.MapFrom(s => s.Address.Name))
.ForMember(
d => d.AddressLongitude,
opt => opt.MapFrom(s => s.Address.Longitude))
.ForMember(
d => d.AddressLatitude,
opt => opt.MapFrom(s => s.Address.Latitude))
.ForMember(
d => d.AddressVehicleType,
opt => opt.MapFrom(s => s.Address.VehicleType.Name))
.ForMember(
d => d.CityUuid,
opt => opt.MapFrom(s => s.Address.City.Guid))
.ForMember(
d => d.CityName,
opt => opt.MapFrom(s => s.Address.City.Name))
.ForMember(
d => d.RegionUuid,
opt => opt.MapFrom(s => s.Address.City.Region.Guid))
.ForMember(
d => d.RegionName,
opt => opt.MapFrom(s => s.Address.City.Region.Name))
.ForMember(
d => d.CountryUuid,
opt => opt.MapFrom(s => s.Address.City.Region.Country.Guid))
.ForMember(
d => d.CountryName,
opt => opt.MapFrom(s => s.Address.City.Region.Country.Name));
}
}

View File

@ -0,0 +1,15 @@
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels;
public sealed class AddVehicleEnrollmentViewModel
{
public DateTimeOffset DepartureTime { get; set; }
public string Currency { get; set; }
public Guid VehicleUuid { get; set; }
public Guid RouteUuid { get; set; }
public ICollection<RouteAddressDetailViewModel> RouteAddressDetails { get; set; }
}

View File

@ -0,0 +1,46 @@
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels;
public sealed class GetVehicleEnrollmentsPageFilterViewModel
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
public string Search { get; set; } = String.Empty;
public string Sort { get; set; } = String.Empty;
public Guid? RouteGuid { get; set; }
public Guid? VehicleGuid { get; set; }
public int? NumberOfAddressesGreaterThanOrEqual { get; set; }
public int? NumberOfAddressesLessThanOrEqual { get; set; }
public DateTimeOffset? DepartureTimeGreaterThanOrEqual { get; set; }
public DateTimeOffset? DepartureTimeLessThanOrEqual { get; set; }
public DateTimeOffset? ArrivalTimeGreaterThanOrEqual { get; set; }
public DateTimeOffset? ArrivalTimeLessThanOrEqual { get; set; }
public TimeSpan? TravelTimeGreaterThanOrEqual { get; set; }
public TimeSpan? TravelTimeLessThanOrEqual { get; set; }
public TimeSpan? TimeMovingGreaterThanOrEqual { get; set; }
public TimeSpan? TimeMovingLessThanOrEqual { get; set; }
public TimeSpan? TimeInStopsGreaterThanOrEqual { get; set; }
public TimeSpan? TimeInStopsLessThanOrEqual { get; set; }
public decimal? CostGreaterThanOrEqual { get; set; }
public decimal? CostLessThanOrEqual { get; set; }
public string? Currency { get; set; }
}

View File

@ -0,0 +1,13 @@
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels;
public sealed class RouteAddressDetailViewModel
{
public TimeSpan TimeToNextAddress { get; set; }
public decimal CostToNextAddress { get; set; }
public TimeSpan CurrentAddressStopTime { get; set; }
public Guid RouteAddressUuid { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels;
public sealed class UpdateVehicleEnrollmentViewModel
{
public DateTimeOffset DepartureTime { get; set; }
public string Currency { get; set; }
public ICollection<RouteAddressDetailViewModel> RouteAddressDetails { get; set; }
}

View File

@ -10,4 +10,6 @@ public sealed class Route : EntityBase
public ICollection<RouteAddress> RouteAddresses { get; set; } public ICollection<RouteAddress> RouteAddresses { get; set; }
public ICollection<VehicleEnrollment> VehicleEnrollments { get; set; }
} }

View File

@ -13,4 +13,7 @@ public sealed class RouteAddress : EntityBase
public long RouteId { get; set; } public long RouteId { get; set; }
public Route Route { get; set; } public Route Route { get; set; }
public ICollection<RouteAddressDetail> Details { get; set; }
} }

View File

@ -0,0 +1,20 @@
namespace cuqmbr.TravelGuide.Domain.Entities;
public sealed class RouteAddressDetail : EntityBase
{
public TimeSpan TimeToNextAddress { get; set; }
public decimal CostToNextAddress { get; set; }
public TimeSpan CurrentAddressStopTime { get; set; }
public long VehicleEnrollmentId { get; set; }
public VehicleEnrollment VehicleEnrollment { get; set; }
public long RouteAddressId { get; set; }
public RouteAddress RouteAddress { get; set; }
}

View File

@ -5,4 +5,7 @@ namespace cuqmbr.TravelGuide.Domain.Entities;
public abstract class Vehicle : EntityBase public abstract class Vehicle : EntityBase
{ {
public VehicleType VehicleType { get; set; } public VehicleType VehicleType { get; set; }
public ICollection<VehicleEnrollment> Enrollments { get; set; }
} }

View File

@ -0,0 +1,23 @@
using cuqmbr.TravelGuide.Domain.Enums;
namespace cuqmbr.TravelGuide.Domain.Entities;
public class VehicleEnrollment : EntityBase
{
public DateTimeOffset DepartureTime { get; set; }
public Currency Currency { get; set; }
public long VehicleId { get; set; }
public Vehicle Vehicle { get; set; }
public long RouteId { get; set; }
public Route Route { get; set; }
public ICollection<RouteAddressDetail> RouteAddressDetails { get; set; }
}

View File

@ -0,0 +1,30 @@
namespace cuqmbr.TravelGuide.Domain.Enums;
// Do not forget to update the schema of your database when changing
// this class (if you use it with a database)
// ISO-4217 Country Codes dated 2025-03-31
public abstract class Currency : Enumeration<Currency>
{
public static readonly Currency USD = new USDCurrency();
public static readonly Currency EUR = new EURCurrency();
public static readonly Currency UAH = new UAHCurrency();
protected Currency(int value, string name) : base(value, name) { }
private sealed class USDCurrency : Currency
{
public USDCurrency() : base(840, "USD") { }
}
private sealed class EURCurrency : Currency
{
public EURCurrency() : base(978, "EUR") { }
}
private sealed class UAHCurrency : Currency
{
public UAHCurrency() : base(980, "UAH") { }
}
}

View File

@ -189,7 +189,8 @@ public class AddressesController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(AddressDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -177,7 +177,8 @@ public class AircraftsController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(AircraftDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -177,7 +177,8 @@ public class BusesController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(BusDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -172,7 +172,8 @@ public class CitiesController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(CityDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -160,7 +160,8 @@ public class CountriesController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(CountryDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -9,7 +9,6 @@ using cuqmbr.TravelGuide.Application.Regions.Queries.GetRegionsPage;
using cuqmbr.TravelGuide.Application.Regions.Queries.GetRegion; using cuqmbr.TravelGuide.Application.Regions.Queries.GetRegion;
using cuqmbr.TravelGuide.Application.Regions.Commands.UpdateRegion; using cuqmbr.TravelGuide.Application.Regions.Commands.UpdateRegion;
using cuqmbr.TravelGuide.Application.Regions.Commands.DeleteRegion; using cuqmbr.TravelGuide.Application.Regions.Commands.DeleteRegion;
using cuqmbr.TravelGuide.Application.Regions.ViewModels;
namespace cuqmbr.TravelGuide.HttpApi.Controllers; namespace cuqmbr.TravelGuide.HttpApi.Controllers;
@ -49,7 +48,8 @@ public class RegionsController : ControllerBase
await Mediator.Send( await Mediator.Send(
new AddRegionCommand() new AddRegionCommand()
{ {
Name = viewModel.Name Name = viewModel.Name,
CountryGuid = viewModel.CountryUuid
}, },
cancellationToken)); cancellationToken));
} }
@ -171,7 +171,8 @@ public class RegionsController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(RegionDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -182,7 +182,8 @@ public class RoutesController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(RouteDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -177,7 +177,8 @@ public class TrainsController : ControllerBase
"Not enough privileges to perform an action", "Not enough privileges to perform an action",
typeof(ProblemDetails))] typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(TrainDto))] StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]

View File

@ -0,0 +1,260 @@
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using cuqmbr.TravelGuide.Domain.Enums;
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Application.Common.ViewModels;
using cuqmbr.TravelGuide.Application.VehicleEnrollments;
using cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.AddVehicleEnrollment;
using cuqmbr.TravelGuide.Application.VehicleEnrollments
.Queries.GetVehicleEnrollmentsPage;
using cuqmbr.TravelGuide.Application.VehicleEnrollments
.Queries.GetVehicleEnrollment;
using cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.UpdateVehicleEnrollment;
using cuqmbr.TravelGuide.Application.VehicleEnrollments
.Commands.DeleteVehicleEnrollment;
using cuqmbr.TravelGuide.Application.VehicleEnrollments.Models;
using cuqmbr.TravelGuide.Application.VehicleEnrollments.ViewModels;
namespace cuqmbr.TravelGuide.HttpApi.Controllers;
[Route("vehicleEnrollments")]
public class VehicleEnrollmentsController : ControllerBase
{
[HttpPost]
[SwaggerOperation("Add a vehicle enrollment")]
[SwaggerResponse(
StatusCodes.Status201Created, "Object successfuly created",
typeof(VehicleEnrollmentDto))]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Input data validation error",
typeof(HttpValidationProblemDetails))]
[SwaggerResponse(
StatusCodes.Status400BadRequest,
"Enrollment travel time overlapping with " +
"other enrollment time of the vehicle",
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, "Given route not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status404NotFound, "Given vehicle not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status404NotFound, "At least one route address not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<ActionResult<VehicleEnrollmentDto>> Add(
[FromBody] AddVehicleEnrollmentViewModel viewModel,
CancellationToken cancellationToken)
{
return StatusCode(
StatusCodes.Status201Created,
await Mediator.Send(
new AddVehicleEnrollmentCommand()
{
DepartureTime = viewModel.DepartureTime,
Currency = Currency.FromName(viewModel.Currency),
VehicleGuid = viewModel.VehicleUuid,
RouteGuid = viewModel.RouteUuid,
RouteAddressDetails = viewModel.RouteAddressDetails.Select(
rad => new RouteAddressDetailModel()
{
TimeToNextAddress = rad.TimeToNextAddress,
CostToNextAddress = rad.CostToNextAddress,
CurrentAddressStopTime = rad.CurrentAddressStopTime,
RouteAddressGuid = rad.RouteAddressUuid
})
.ToArray()
},
cancellationToken));
}
[HttpGet]
[SwaggerOperation("Get a list of all vehicle enrollments")]
[SwaggerResponse(
StatusCodes.Status200OK, "Request successful",
typeof(PaginatedList<VehicleEnrollmentDto>))]
[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.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<PaginatedList<VehicleEnrollmentDto>> GetPage(
[FromQuery] PageQuery pageQuery, [FromQuery] SearchQuery searchQuery,
[FromQuery] SortQuery sortQuery,
[FromQuery] GetVehicleEnrollmentsPageFilterViewModel filterQuery,
CancellationToken cancellationToken)
{
return await Mediator.Send(
new GetVehicleEnrollmentsPageQuery()
{
PageNumber = pageQuery.PageNumber,
PageSize = pageQuery.PageSize,
// Search = searchQuery.Search,
Sort = sortQuery.Sort,
RouteGuid = filterQuery.RouteGuid,
VehicleGuid = filterQuery.VehicleGuid,
NumberOfAddressesGreaterThanOrEqual =
filterQuery.NumberOfAddressesGreaterThanOrEqual,
NumberOfAddressesLessThanOrEqual =
filterQuery.NumberOfAddressesLessThanOrEqual,
DepartureTimeGreaterThanOrEqual =
filterQuery.DepartureTimeGreaterThanOrEqual,
DepartureTimeLessThanOrEqual =
filterQuery.DepartureTimeLessThanOrEqual,
ArrivalTimeGreaterThanOrEqual =
filterQuery.ArrivalTimeGreaterThanOrEqual,
ArrivalTimeLessThanOrEqual =
filterQuery.ArrivalTimeLessThanOrEqual,
TravelTimeGreaterThanOrEqual =
filterQuery.TravelTimeGreaterThanOrEqual,
TravelTimeLessThanOrEqual =
filterQuery.TravelTimeLessThanOrEqual,
TimeMovingGreaterThanOrEqual =
filterQuery.TimeMovingGreaterThanOrEqual,
TimeMovingLessThanOrEqual =
filterQuery.TimeMovingLessThanOrEqual,
TimeInStopsGreaterThanOrEqual =
filterQuery.TimeInStopsGreaterThanOrEqual,
TimeInStopsLessThanOrEqual =
filterQuery.TimeInStopsLessThanOrEqual,
CostGreaterThanOrEqual =
filterQuery.CostGreaterThanOrEqual,
CostLessThanOrEqual =
filterQuery.CostLessThanOrEqual,
Currency = Currency.FromName(filterQuery.Currency)
},
cancellationToken);
}
[HttpGet("{uuid:guid}")]
[SwaggerOperation("Get a vehicle enrollment by uuid")]
[SwaggerResponse(
StatusCodes.Status200OK, "Request successful",
typeof(VehicleEnrollmentDto))]
[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, "Object not found",
typeof(VehicleEnrollmentDto))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<VehicleEnrollmentDto> Get(
[FromRoute] Guid uuid,
CancellationToken cancellationToken)
{
return await Mediator.Send(
new GetVehicleEnrollmentQuery() { Guid = uuid },
cancellationToken);
}
[HttpPut("{uuid:guid}")]
[SwaggerOperation("Update a vehicle enrollment")]
[SwaggerResponse(
StatusCodes.Status200OK, "Request successful",
typeof(VehicleEnrollmentDto))]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Input data validation error",
typeof(HttpValidationProblemDetails))]
[SwaggerResponse(
StatusCodes.Status400BadRequest,
"Enrollment travel time overlapping with " +
"other enrollment time of the vehicle",
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, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status404NotFound, "At least one route address not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<VehicleEnrollmentDto> Update(
[FromRoute] Guid uuid,
[FromBody] UpdateVehicleEnrollmentViewModel viewModel,
CancellationToken cancellationToken)
{
return await Mediator.Send(
new UpdateVehicleEnrollmentCommand()
{
Guid = uuid,
DepartureTime = viewModel.DepartureTime,
Currency = Currency.FromName(viewModel.Currency),
RouteAddressDetails = viewModel.RouteAddressDetails.Select(
rad => new RouteAddressDetailModel()
{
TimeToNextAddress = rad.TimeToNextAddress,
CostToNextAddress = rad.CostToNextAddress,
CurrentAddressStopTime = rad.CurrentAddressStopTime,
RouteAddressGuid = rad.RouteAddressUuid
})
.ToArray()
},
cancellationToken);
}
[HttpDelete("{uuid:guid}")]
[SwaggerOperation("Delete a vehicle enrollment")]
[SwaggerResponse(StatusCodes.Status204NoContent, "Request successful")]
[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, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<IActionResult> Delete(
[FromRoute] Guid uuid,
CancellationToken cancellationToken)
{
await Mediator.Send(
new DeleteVehicleEnrollmentCommand() { Guid = uuid },
cancellationToken);
return StatusCode(StatusCodes.Status204NoContent);
}
}

View File

@ -27,5 +27,10 @@ public class InMemoryDbContext : DbContext
.Properties<VehicleType>() .Properties<VehicleType>()
.HaveColumnType("vehicle_type") .HaveColumnType("vehicle_type")
.HaveConversion<VehicleTypeConverter>(); .HaveConversion<VehicleTypeConverter>();
builder
.Properties<Currency>()
.HaveColumnType("currency")
.HaveConversion<CurrencyConverter>();
} }
} }

View File

@ -22,6 +22,10 @@ public sealed class InMemoryUnitOfWork : UnitOfWork
BusRepository = new InMemoryBusRepository(_dbContext); BusRepository = new InMemoryBusRepository(_dbContext);
AircraftRepository = new InMemoryAircraftRepository(_dbContext); AircraftRepository = new InMemoryAircraftRepository(_dbContext);
TrainRepository = new InMemoryTrainRepository(_dbContext); TrainRepository = new InMemoryTrainRepository(_dbContext);
VehicleEnrollmentRepository =
new InMemoryVehicleEnrollmentRepository(_dbContext);
RouteAddressRepository =
new InMemoryRouteAddressRepository(_dbContext);
} }
public CountryRepository CountryRepository { get; init; } public CountryRepository CountryRepository { get; init; }
@ -42,6 +46,10 @@ public sealed class InMemoryUnitOfWork : UnitOfWork
public TrainRepository TrainRepository { get; init; } public TrainRepository TrainRepository { get; init; }
public VehicleEnrollmentRepository VehicleEnrollmentRepository { get; init; }
public RouteAddressRepository RouteAddressRepository { get; init; }
public int Save() public int Save()
{ {
return _dbContext.SaveChanges(); return _dbContext.SaveChanges();

View File

@ -0,0 +1,11 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Persistence.InMemory.Repositories;
public sealed class InMemoryRouteAddressRepository :
InMemoryBaseRepository<RouteAddress>, RouteAddressRepository
{
public InMemoryRouteAddressRepository(InMemoryDbContext dbContext)
: base(dbContext) { }
}

View File

@ -0,0 +1,11 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Persistence.InMemory.Repositories;
public sealed class InMemoryVehicleEnrollmentRepository :
InMemoryBaseRepository<VehicleEnrollment>, VehicleEnrollmentRepository
{
public InMemoryVehicleEnrollmentRepository(InMemoryDbContext dbContext)
: base(dbContext) { }
}

View File

@ -0,0 +1,85 @@
using cuqmbr.TravelGuide.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Configurations;
public class RouteAddressDetailConfiguration :
BaseConfiguration<RouteAddressDetail>
{
public override void Configure(EntityTypeBuilder<RouteAddressDetail> builder)
{
builder
.ToTable("route_address_details");
base.Configure(builder);
builder
.Property(rad => rad.TimeToNextAddress)
.HasColumnName("time_to_next_address")
.HasColumnType("interval")
.IsRequired(true);
builder
.Property(rad => rad.CostToNextAddress)
.HasColumnName("cost_to_next_address")
.HasColumnType("numeric(24,12)")
.IsRequired(true);
builder
.Property(rad => rad.CurrentAddressStopTime)
.HasColumnName("current_address_stop_time")
.HasColumnType("interval")
.IsRequired(true);
builder
.Property(rad => rad.VehicleEnrollmentId)
.HasColumnName("vehicle_enrollment_id")
.HasColumnType("bigint")
.IsRequired(true);
builder
.HasOne(rad => rad.VehicleEnrollment)
.WithMany(ve => ve.RouteAddressDetails)
.HasForeignKey(rad => rad.VehicleEnrollmentId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(rad => rad.VehicleEnrollmentId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.Cascade);
builder
.HasIndex(rad => rad.VehicleEnrollmentId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(rad => rad.VehicleEnrollmentId).Metadata.GetColumnName()}");
builder
.Property(rad => rad.RouteAddressId)
.HasColumnName("route_address_id")
.HasColumnType("bigint")
.IsRequired(true);
builder
.HasOne(rad => rad.RouteAddress)
.WithMany(ra => ra.Details)
.HasForeignKey(rad => rad.RouteAddressId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(rad => rad.RouteAddressId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.Cascade);
builder
.HasIndex(rad => rad.RouteAddressId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(rad => rad.RouteAddressId).Metadata.GetColumnName()}");
}
}

View File

@ -0,0 +1,88 @@
using cuqmbr.TravelGuide.Domain.Entities;
using cuqmbr.TravelGuide.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Configurations;
public class VehicleEnrollmentConfiguration : BaseConfiguration<VehicleEnrollment>
{
public override void Configure(EntityTypeBuilder<VehicleEnrollment> builder)
{
builder
.Property(ve => ve.Currency)
.HasColumnName("currency")
.HasColumnType("varchar(8)")
.IsRequired(true);
builder
.ToTable(
"vehicle_enrollments",
ve => ve.HasCheckConstraint(
"ck_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(ve => ve.Currency)
.Metadata.GetColumnName()}",
$"{builder.Property(ve => ve.Currency)
.Metadata.GetColumnName()} IN ('{String
.Join("', '", Currency.Enumerations
.Values.Select(v => v.Name))}')"));
base.Configure(builder);
builder
.Property(ve => ve.DepartureTime)
.HasColumnName("departure_time")
.HasColumnType("timestamptz")
.IsRequired(true);
builder
.Property(ve => ve.VehicleId)
.HasColumnName("vehicle_id")
.HasColumnType("bigint")
.IsRequired(true);
builder
.HasOne(ve => ve.Vehicle)
.WithMany(v => v.Enrollments)
.HasForeignKey(ve => ve.VehicleId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(ve => ve.VehicleId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.Cascade);
builder
.HasIndex(ve => ve.VehicleId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(ve => ve.VehicleId).Metadata.GetColumnName()}");
builder
.Property(ve => ve.RouteId)
.HasColumnName("route_id")
.HasColumnType("bigint")
.IsRequired(true);
builder
.HasOne(ve => ve.Route)
.WithMany(r => r.VehicleEnrollments)
.HasForeignKey(ve => ve.RouteId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(ve => ve.RouteId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.Cascade);
builder
.HasIndex(ve => ve.RouteId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(ve => ve.RouteId).Metadata.GetColumnName()}");
}
}

View File

@ -0,0 +1,637 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using cuqmbr.TravelGuide.Persistence.PostgreSql;
#nullable disable
namespace Persistence.PostgreSql.Migrations
{
[DbContext(typeof(PostgreSqlDbContext))]
[Migration("20250504143929_Add_Vehicle_Enrollment_and_Route_Address_Detail")]
partial class Add_Vehicle_Enrollment_and_Route_Address_Detail
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("application")
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.HasSequence("addresses_id_sequence");
modelBuilder.HasSequence("cities_id_sequence");
modelBuilder.HasSequence("countries_id_sequence");
modelBuilder.HasSequence("regions_id_sequence");
modelBuilder.HasSequence("route_address_details_id_sequence");
modelBuilder.HasSequence("route_addresses_id_sequence");
modelBuilder.HasSequence("routes_id_sequence");
modelBuilder.HasSequence("vehicle_enrollments_id_sequence");
modelBuilder.HasSequence("vehicles_id_sequence");
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.addresses_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "addresses_id_sequence");
b.Property<long>("CityId")
.HasColumnType("bigint")
.HasColumnName("city_id");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<double>("Latitude")
.HasColumnType("double precision");
b.Property<double>("Longitude")
.HasColumnType("double precision");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(128)")
.HasColumnName("name");
b.Property<string>("VehicleType")
.IsRequired()
.HasColumnType("varchar(16)")
.HasColumnName("vehicle_type");
b.HasKey("Id")
.HasName("pk_addresses");
b.HasAlternateKey("Guid")
.HasName("altk_addresses_uuid");
b.HasIndex("CityId")
.HasDatabaseName("ix_addresses_city_id");
b.ToTable("addresses", "application", t =>
{
t.HasCheckConstraint("ck_addresses_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')");
});
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.cities_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "cities_id_sequence");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(64)")
.HasColumnName("name");
b.Property<long>("RegionId")
.HasColumnType("bigint")
.HasColumnName("region_id");
b.HasKey("Id")
.HasName("pk_cities");
b.HasAlternateKey("Guid")
.HasName("altk_cities_uuid");
b.HasIndex("RegionId")
.HasDatabaseName("ix_cities_region_id");
b.ToTable("cities", "application");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Country", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.countries_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "countries_id_sequence");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(64)")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_countries");
b.HasAlternateKey("Guid")
.HasName("altk_countries_uuid");
b.ToTable("countries", "application");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.regions_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "regions_id_sequence");
b.Property<long>("CountryId")
.HasColumnType("bigint")
.HasColumnName("country_id");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(64)")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_regions");
b.HasAlternateKey("Guid")
.HasName("altk_regions_uuid");
b.HasIndex("CountryId")
.HasDatabaseName("ix_regions_country_id");
b.ToTable("regions", "application");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.routes_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "routes_id_sequence");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(64)")
.HasColumnName("name");
b.Property<string>("VehicleType")
.IsRequired()
.HasColumnType("varchar(16)")
.HasColumnName("vehicle_type");
b.HasKey("Id")
.HasName("pk_routes");
b.HasAlternateKey("Guid")
.HasName("altk_routes_uuid");
b.ToTable("routes", "application", t =>
{
t.HasCheckConstraint("ck_routes_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')");
});
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.route_addresses_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "route_addresses_id_sequence");
b.Property<long>("AddressId")
.HasColumnType("bigint")
.HasColumnName("address_id");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<short>("Order")
.HasColumnType("smallint")
.HasColumnName("order");
b.Property<long>("RouteId")
.HasColumnType("bigint")
.HasColumnName("route_id");
b.HasKey("Id")
.HasName("pk_route_addresses");
b.HasAlternateKey("Guid")
.HasName("altk_route_addresses_uuid");
b.HasAlternateKey("AddressId", "RouteId", "Order")
.HasName("altk_route_addresses_address_id_route_id_order");
b.HasIndex("AddressId")
.HasDatabaseName("ix_route_addresses_address_id");
b.HasIndex("RouteId")
.HasDatabaseName("ix_route_addresses_route_id");
b.ToTable("route_addresses", "application");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.route_address_details_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "route_address_details_id_sequence");
b.Property<decimal>("CostToNextAddress")
.HasColumnType("numeric(24,12)")
.HasColumnName("cost_to_next_address");
b.Property<TimeSpan>("CurrentAddressStopTime")
.HasColumnType("interval")
.HasColumnName("current_address_stop_time");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<long>("RouteAddressId")
.HasColumnType("bigint")
.HasColumnName("route_address_id");
b.Property<TimeSpan>("TimeToNextAddress")
.HasColumnType("interval")
.HasColumnName("time_to_next_address");
b.Property<long>("VehicleEnrollmentId")
.HasColumnType("bigint")
.HasColumnName("vehicle_enrollment_id");
b.HasKey("Id")
.HasName("pk_route_address_details");
b.HasAlternateKey("Guid")
.HasName("altk_route_address_details_uuid");
b.HasIndex("RouteAddressId")
.HasDatabaseName("ix_route_address_details_route_address_id");
b.HasIndex("VehicleEnrollmentId")
.HasDatabaseName("ix_route_address_details_vehicle_enrollment_id");
b.ToTable("route_address_details", "application");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.vehicles_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "vehicles_id_sequence");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<string>("VehicleType")
.IsRequired()
.HasColumnType("varchar(16)")
.HasColumnName("vehicle_type");
b.HasKey("Id")
.HasName("pk_vehicles");
b.HasAlternateKey("Guid")
.HasName("altk_vehicles_uuid");
b.ToTable("vehicles", "application", t =>
{
t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')");
});
b.HasDiscriminator<string>("VehicleType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.vehicle_enrollments_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "vehicle_enrollments_id_sequence");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("varchar(8)")
.HasColumnName("currency");
b.Property<DateTimeOffset>("DepartureTime")
.HasColumnType("timestamptz")
.HasColumnName("departure_time");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<long>("RouteId")
.HasColumnType("bigint")
.HasColumnName("route_id");
b.Property<long>("VehicleId")
.HasColumnType("bigint")
.HasColumnName("vehicle_id");
b.HasKey("Id")
.HasName("pk_vehicle_enrollments");
b.HasAlternateKey("Guid")
.HasName("altk_vehicle_enrollments_uuid");
b.HasIndex("RouteId")
.HasDatabaseName("ix_vehicle_enrollments_route_id");
b.HasIndex("VehicleId")
.HasDatabaseName("ix_vehicle_enrollments_vehicle_id");
b.ToTable("vehicle_enrollments", "application", t =>
{
t.HasCheckConstraint("ck_vehicle_enrollments_currency", "currency IN ('USD', 'EUR', 'UAH')");
});
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Aircraft", b =>
{
b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle");
b.Property<short>("Capacity")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("smallint")
.HasColumnName("capacity");
b.Property<string>("Model")
.IsRequired()
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("varchar(64)")
.HasColumnName("model");
b.Property<string>("Number")
.IsRequired()
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("varchar(32)")
.HasColumnName("number");
b.ToTable(t =>
{
t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')");
});
b.HasDiscriminator().HasValue("aircraft");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Bus", b =>
{
b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle");
b.Property<short>("Capacity")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("smallint")
.HasColumnName("capacity");
b.Property<string>("Model")
.IsRequired()
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("varchar(64)")
.HasColumnName("model");
b.Property<string>("Number")
.IsRequired()
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("varchar(32)")
.HasColumnName("number");
b.ToTable(t =>
{
t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')");
});
b.HasDiscriminator().HasValue("bus");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Train", b =>
{
b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle");
b.Property<short>("Capacity")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("smallint")
.HasColumnName("capacity");
b.Property<string>("Model")
.IsRequired()
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("varchar(64)")
.HasColumnName("model");
b.Property<string>("Number")
.IsRequired()
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("varchar(32)")
.HasColumnName("number");
b.ToTable(t =>
{
t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')");
});
b.HasDiscriminator().HasValue("train");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.City", "City")
.WithMany("Addresses")
.HasForeignKey("CityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_addresses_city_id");
b.Navigation("City");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Region", "Region")
.WithMany("Cities")
.HasForeignKey("RegionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cities_region_id");
b.Navigation("Region");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Country", "Country")
.WithMany("Regions")
.HasForeignKey("CountryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_regions_country_id");
b.Navigation("Country");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Address", "Address")
.WithMany("AddressRoutes")
.HasForeignKey("AddressId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_route_addresses_address_id");
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route")
.WithMany("RouteAddresses")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_route_addresses_route_id");
b.Navigation("Address");
b.Navigation("Route");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", "RouteAddress")
.WithMany("Details")
.HasForeignKey("RouteAddressId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_route_address_details_route_address_id");
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", "VehicleEnrollment")
.WithMany("RouteAddressDetails")
.HasForeignKey("VehicleEnrollmentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_route_address_details_vehicle_enrollment_id");
b.Navigation("RouteAddress");
b.Navigation("VehicleEnrollment");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route")
.WithMany("VehicleEnrollments")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_vehicle_enrollments_route_id");
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Vehicle", "Vehicle")
.WithMany("Enrollments")
.HasForeignKey("VehicleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_vehicle_enrollments_vehicle_id");
b.Navigation("Route");
b.Navigation("Vehicle");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b =>
{
b.Navigation("AddressRoutes");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b =>
{
b.Navigation("Addresses");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Country", b =>
{
b.Navigation("Regions");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b =>
{
b.Navigation("Cities");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b =>
{
b.Navigation("RouteAddresses");
b.Navigation("VehicleEnrollments");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b =>
{
b.Navigation("Details");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b =>
{
b.Navigation("Enrollments");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b =>
{
b.Navigation("RouteAddressDetails");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,133 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Persistence.PostgreSql.Migrations
{
/// <inheritdoc />
public partial class Add_Vehicle_Enrollment_and_Route_Address_Detail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateSequence(
name: "route_address_details_id_sequence",
schema: "application");
migrationBuilder.CreateSequence(
name: "vehicle_enrollments_id_sequence",
schema: "application");
migrationBuilder.CreateTable(
name: "vehicle_enrollments",
schema: "application",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false, defaultValueSql: "nextval('application.vehicle_enrollments_id_sequence')"),
departure_time = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false),
currency = table.Column<string>(type: "varchar(8)", nullable: false),
vehicle_id = table.Column<long>(type: "bigint", nullable: false),
route_id = table.Column<long>(type: "bigint", nullable: false),
uuid = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_vehicle_enrollments", x => x.id);
table.UniqueConstraint("altk_vehicle_enrollments_uuid", x => x.uuid);
table.CheckConstraint("ck_vehicle_enrollments_currency", "currency IN ('USD', 'EUR', 'UAH')");
table.ForeignKey(
name: "fk_vehicle_enrollments_route_id",
column: x => x.route_id,
principalSchema: "application",
principalTable: "routes",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_vehicle_enrollments_vehicle_id",
column: x => x.vehicle_id,
principalSchema: "application",
principalTable: "vehicles",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "route_address_details",
schema: "application",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false, defaultValueSql: "nextval('application.route_address_details_id_sequence')"),
time_to_next_address = table.Column<TimeSpan>(type: "interval", nullable: false),
cost_to_next_address = table.Column<decimal>(type: "numeric(24,12)", nullable: false),
current_address_stop_time = table.Column<TimeSpan>(type: "interval", nullable: false),
vehicle_enrollment_id = table.Column<long>(type: "bigint", nullable: false),
route_address_id = table.Column<long>(type: "bigint", nullable: false),
uuid = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_route_address_details", x => x.id);
table.UniqueConstraint("altk_route_address_details_uuid", x => x.uuid);
table.ForeignKey(
name: "fk_route_address_details_route_address_id",
column: x => x.route_address_id,
principalSchema: "application",
principalTable: "route_addresses",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_route_address_details_vehicle_enrollment_id",
column: x => x.vehicle_enrollment_id,
principalSchema: "application",
principalTable: "vehicle_enrollments",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_route_address_details_route_address_id",
schema: "application",
table: "route_address_details",
column: "route_address_id");
migrationBuilder.CreateIndex(
name: "ix_route_address_details_vehicle_enrollment_id",
schema: "application",
table: "route_address_details",
column: "vehicle_enrollment_id");
migrationBuilder.CreateIndex(
name: "ix_vehicle_enrollments_route_id",
schema: "application",
table: "vehicle_enrollments",
column: "route_id");
migrationBuilder.CreateIndex(
name: "ix_vehicle_enrollments_vehicle_id",
schema: "application",
table: "vehicle_enrollments",
column: "vehicle_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "route_address_details",
schema: "application");
migrationBuilder.DropTable(
name: "vehicle_enrollments",
schema: "application");
migrationBuilder.DropSequence(
name: "route_address_details_id_sequence",
schema: "application");
migrationBuilder.DropSequence(
name: "vehicle_enrollments_id_sequence",
schema: "application");
}
}
}

View File

@ -31,10 +31,14 @@ namespace Persistence.PostgreSql.Migrations
modelBuilder.HasSequence("regions_id_sequence"); modelBuilder.HasSequence("regions_id_sequence");
modelBuilder.HasSequence("route_address_details_id_sequence");
modelBuilder.HasSequence("route_addresses_id_sequence"); modelBuilder.HasSequence("route_addresses_id_sequence");
modelBuilder.HasSequence("routes_id_sequence"); modelBuilder.HasSequence("routes_id_sequence");
modelBuilder.HasSequence("vehicle_enrollments_id_sequence");
modelBuilder.HasSequence("vehicles_id_sequence"); modelBuilder.HasSequence("vehicles_id_sequence");
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b =>
@ -264,6 +268,55 @@ namespace Persistence.PostgreSql.Migrations
b.ToTable("route_addresses", "application"); b.ToTable("route_addresses", "application");
}); });
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.route_address_details_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "route_address_details_id_sequence");
b.Property<decimal>("CostToNextAddress")
.HasColumnType("numeric(24,12)")
.HasColumnName("cost_to_next_address");
b.Property<TimeSpan>("CurrentAddressStopTime")
.HasColumnType("interval")
.HasColumnName("current_address_stop_time");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<long>("RouteAddressId")
.HasColumnType("bigint")
.HasColumnName("route_address_id");
b.Property<TimeSpan>("TimeToNextAddress")
.HasColumnType("interval")
.HasColumnName("time_to_next_address");
b.Property<long>("VehicleEnrollmentId")
.HasColumnType("bigint")
.HasColumnName("vehicle_enrollment_id");
b.HasKey("Id")
.HasName("pk_route_address_details");
b.HasAlternateKey("Guid")
.HasName("altk_route_address_details_uuid");
b.HasIndex("RouteAddressId")
.HasDatabaseName("ix_route_address_details_route_address_id");
b.HasIndex("VehicleEnrollmentId")
.HasDatabaseName("ix_route_address_details_vehicle_enrollment_id");
b.ToTable("route_address_details", "application");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -299,6 +352,55 @@ namespace Persistence.PostgreSql.Migrations
b.UseTphMappingStrategy(); b.UseTphMappingStrategy();
}); });
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id")
.HasDefaultValueSql("nextval('application.vehicle_enrollments_id_sequence')");
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "vehicle_enrollments_id_sequence");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("varchar(8)")
.HasColumnName("currency");
b.Property<DateTimeOffset>("DepartureTime")
.HasColumnType("timestamptz")
.HasColumnName("departure_time");
b.Property<Guid>("Guid")
.HasColumnType("uuid")
.HasColumnName("uuid");
b.Property<long>("RouteId")
.HasColumnType("bigint")
.HasColumnName("route_id");
b.Property<long>("VehicleId")
.HasColumnType("bigint")
.HasColumnName("vehicle_id");
b.HasKey("Id")
.HasName("pk_vehicle_enrollments");
b.HasAlternateKey("Guid")
.HasName("altk_vehicle_enrollments_uuid");
b.HasIndex("RouteId")
.HasDatabaseName("ix_vehicle_enrollments_route_id");
b.HasIndex("VehicleId")
.HasDatabaseName("ix_vehicle_enrollments_vehicle_id");
b.ToTable("vehicle_enrollments", "application", t =>
{
t.HasCheckConstraint("ck_vehicle_enrollments_currency", "currency IN ('USD', 'EUR', 'UAH')");
});
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Aircraft", b => modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Aircraft", b =>
{ {
b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle");
@ -443,6 +545,48 @@ namespace Persistence.PostgreSql.Migrations
b.Navigation("Route"); b.Navigation("Route");
}); });
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", "RouteAddress")
.WithMany("Details")
.HasForeignKey("RouteAddressId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_route_address_details_route_address_id");
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", "VehicleEnrollment")
.WithMany("RouteAddressDetails")
.HasForeignKey("VehicleEnrollmentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_route_address_details_vehicle_enrollment_id");
b.Navigation("RouteAddress");
b.Navigation("VehicleEnrollment");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b =>
{
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route")
.WithMany("VehicleEnrollments")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_vehicle_enrollments_route_id");
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Vehicle", "Vehicle")
.WithMany("Enrollments")
.HasForeignKey("VehicleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_vehicle_enrollments_vehicle_id");
b.Navigation("Route");
b.Navigation("Vehicle");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b =>
{ {
b.Navigation("AddressRoutes"); b.Navigation("AddressRoutes");
@ -466,6 +610,23 @@ namespace Persistence.PostgreSql.Migrations
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b => modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b =>
{ {
b.Navigation("RouteAddresses"); b.Navigation("RouteAddresses");
b.Navigation("VehicleEnrollments");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b =>
{
b.Navigation("Details");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b =>
{
b.Navigation("Enrollments");
});
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b =>
{
b.Navigation("RouteAddressDetails");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }

View File

@ -36,7 +36,16 @@ public class PostgreSqlDbContext : DbContext
{ {
builder builder
.Properties<VehicleType>() .Properties<VehicleType>()
.HaveColumnType("vehicle_type") .HaveColumnType("varchar(16)")
.HaveConversion<VehicleTypeConverter>(); .HaveConversion<VehicleTypeConverter>();
builder
.Properties<Currency>()
.HaveColumnType("varchar(8)")
.HaveConversion<CurrencyConverter>();
builder
.Properties<DateTimeOffset>()
.HaveConversion<DateTimeOffsetConverter>();
} }
} }

View File

@ -22,6 +22,10 @@ public sealed class PostgreSqlUnitOfWork : UnitOfWork
BusRepository = new PostgreSqlBusRepository(_dbContext); BusRepository = new PostgreSqlBusRepository(_dbContext);
AircraftRepository = new PostgreSqlAircraftRepository(_dbContext); AircraftRepository = new PostgreSqlAircraftRepository(_dbContext);
TrainRepository = new PostgreSqlTrainRepository(_dbContext); TrainRepository = new PostgreSqlTrainRepository(_dbContext);
VehicleEnrollmentRepository =
new PostgreSqlVehicleEnrollmentRepository(_dbContext);
RouteAddressRepository =
new PostgreSqlRouteAddressRepository(_dbContext);
} }
public CountryRepository CountryRepository { get; init; } public CountryRepository CountryRepository { get; init; }
@ -42,6 +46,10 @@ public sealed class PostgreSqlUnitOfWork : UnitOfWork
public TrainRepository TrainRepository { get; init; } public TrainRepository TrainRepository { get; init; }
public VehicleEnrollmentRepository VehicleEnrollmentRepository { get; init; }
public RouteAddressRepository RouteAddressRepository { get; init; }
public int Save() public int Save()
{ {
return _dbContext.SaveChanges(); return _dbContext.SaveChanges();

View File

@ -0,0 +1,11 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Repositories;
public sealed class PostgreSqlRouteAddressRepository :
PostgreSqlBaseRepository<RouteAddress>, RouteAddressRepository
{
public PostgreSqlRouteAddressRepository(PostgreSqlDbContext dbContext)
: base(dbContext) { }
}

View File

@ -0,0 +1,11 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence.Repositories;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Persistence.PostgreSql.Repositories;
public sealed class PostgreSqlVehicleEnrollmentRepository :
PostgreSqlBaseRepository<VehicleEnrollment>, VehicleEnrollmentRepository
{
public PostgreSqlVehicleEnrollmentRepository(PostgreSqlDbContext dbContext)
: base(dbContext) { }
}

View File

@ -0,0 +1,13 @@
using cuqmbr.TravelGuide.Domain.Enums;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace cuqmbr.TravelGuide.Persistence.TypeConverters;
public class CurrencyConverter : ValueConverter<Currency, string>
{
public CurrencyConverter()
: base(
v => v.Name,
v => Currency.FromName(v))
{ }
}

View File

@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace cuqmbr.TravelGuide.Persistence.TypeConverters;
// Convert localized time to UTC
public class DateTimeOffsetConverter :
ValueConverter<DateTimeOffset, DateTimeOffset>
{
public DateTimeOffsetConverter()
: base(
v => v.ToUniversalTime(),
v => v)
{ }
}