350 lines
13 KiB
C#
350 lines
13 KiB
C#
using Distribution.Features.Stock;
|
|
using Distribution.Features.DriverProfile;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
|
|
namespace Distribution.Features.Ride;
|
|
|
|
public class RideService(
|
|
IRideRepository rideRepo,
|
|
IDriverProfileRepository driverRepo,
|
|
IRideCancelRequestRepository cancelRepo,
|
|
Features.Vehicle.IVehicleRepository vehicleRepo,
|
|
IStockRepository stockRepo,
|
|
IUnitOfWork uow)
|
|
{
|
|
public async Task<PagedResponse<RideResponse>> GetAllAsync(
|
|
RideStatus? status,
|
|
RideType? type,
|
|
Guid? driverProfileId,
|
|
Guid? vehicleId,
|
|
int page,
|
|
int pageSize)
|
|
{
|
|
var total = await rideRepo.GetCountAsync(status, type, driverProfileId, vehicleId);
|
|
var items = await rideRepo.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
|
|
return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
|
}
|
|
|
|
public async Task<RideResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var ride = await rideRepo.GetByIdAsync(id);
|
|
return ride is null ? null : ToResponse(ride);
|
|
}
|
|
|
|
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
|
|
{
|
|
var vehicle = await vehicleRepo.GetByIdAsync(request.FK_VehicleId)
|
|
?? throw new InvalidOperationException("Vehicle not found");
|
|
|
|
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.FK_SourceWarehouseId;
|
|
if (!sourceWarehouseId.HasValue)
|
|
throw new InvalidOperationException("Source warehouse is required for this vehicle");
|
|
|
|
var ride = new DeliveryRun
|
|
{
|
|
RideType = request.RideType,
|
|
FK_VehicleId = request.FK_VehicleId,
|
|
FK_DriverProfileId = request.FK_DriverProfileId,
|
|
FK_RouteId = request.FK_RouteId,
|
|
FK_SourceWarehouseId = sourceWarehouseId.Value,
|
|
Status = RideStatus.Draft
|
|
};
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await rideRepo.AddAsync(ride);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
|
|
var saved = await rideRepo.GetByIdAsync(ride.Id);
|
|
return ToResponse(saved!);
|
|
}
|
|
|
|
public async Task<RideResponse> OpenAsync(Guid rideId, CancellationToken ct = default)
|
|
{
|
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
if (ride.Status != RideStatus.Draft)
|
|
throw new InvalidOperationException($"Cannot open a ride in {ride.Status} status");
|
|
|
|
if (await rideRepo.HasOpenOrActiveRideForVehicleAsync(ride.FK_VehicleId, ride.Id))
|
|
throw new InvalidOperationException("Vehicle already has an open or active ride");
|
|
|
|
await CheckDriverComplianceAsync(ride.FK_DriverProfileId);
|
|
|
|
ride.Status = RideStatus.Open;
|
|
ride.StartedAt = DateTime.Now;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToResponse(ride);
|
|
}
|
|
|
|
public async Task<RideResponse> AssignDriverAsync(Guid rideId, Guid driverProfileId, CancellationToken ct = default)
|
|
{
|
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
if (ride.Status != RideStatus.Draft)
|
|
throw new InvalidOperationException("Driver can only be changed while the ride is in Draft status");
|
|
|
|
var driver = await driverRepo.GetByIdAsync(driverProfileId);
|
|
if (driver is null || !driver.IsActive)
|
|
throw new InvalidOperationException("Driver profile not found or inactive");
|
|
|
|
ride.FK_DriverProfileId = driverProfileId;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var saved = await rideRepo.GetByIdAsync(rideId);
|
|
return ToResponse(saved!);
|
|
}
|
|
|
|
public async Task<RideResponse> ActivateAsync(Guid rideId, ActivateRideRequest request, CancellationToken ct = default)
|
|
{
|
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
if (ride.Status != RideStatus.Open)
|
|
throw new InvalidOperationException($"Cannot activate a ride in {ride.Status} status");
|
|
|
|
ride.Status = RideStatus.Active;
|
|
ride.ActivatedAt = DateTime.Now;
|
|
ride.Meterstart = request.Meterstart;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToResponse(ride);
|
|
}
|
|
|
|
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
|
|
{
|
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
if (ride.Status != RideStatus.PendingClose)
|
|
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
|
|
|
|
ride.Status = RideStatus.Closed;
|
|
ride.ClosedAt = DateTime.Now;
|
|
ride.Meterend = request.Meterend;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToResponse(ride);
|
|
}
|
|
|
|
public async Task<RideCancelRequestResponse> RaiseCancelRequestAsync(Guid rideId, RaiseCancelRequest request, Guid requestedBy, CancellationToken ct = default)
|
|
{
|
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
if (ride.Status != RideStatus.Active)
|
|
throw new InvalidOperationException($"Cannot raise a cancel request for a ride in {ride.Status} status");
|
|
|
|
var existing = await cancelRepo.GetPendingByRideIdAsync(rideId);
|
|
if (existing is not null)
|
|
throw new InvalidOperationException("There is already a pending cancel request for this ride");
|
|
|
|
var cancelRequest = new RideCancelRequest
|
|
{
|
|
FK_RideId = rideId,
|
|
TypeCode = request.TypeCode,
|
|
Reason = request.Reason,
|
|
RequestedBy = requestedBy,
|
|
RequestedAt = DateTime.Now,
|
|
Status = CancelRequestStatus.Pending
|
|
};
|
|
|
|
ride.Status = RideStatus.CancelRequested;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await cancelRepo.AddAsync(cancelRequest);
|
|
rideRepo.Update(ride);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToCancelResponse(cancelRequest);
|
|
}
|
|
|
|
public async Task<PagedResponse<RideCancelRequestResponse>> GetCancelRequestsAsync(CancelRequestStatus? status, int page, int pageSize)
|
|
{
|
|
var total = await cancelRepo.GetCountAsync(status);
|
|
var items = await cancelRepo.GetAllAsync(status, page, pageSize);
|
|
return new PagedResponse<RideCancelRequestResponse>(page, pageSize, total, items.Select(ToCancelResponse).ToList());
|
|
}
|
|
|
|
public async Task<RideCancelRequestResponse> ApproveCancelAsync(Guid cancelRequestId, Guid approvedBy, CancellationToken ct = default)
|
|
{
|
|
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
|
|
?? throw new KeyNotFoundException("Cancel request not found");
|
|
|
|
if (cancelRequest.Status != CancelRequestStatus.Pending)
|
|
throw new InvalidOperationException("Cancel request is not pending");
|
|
|
|
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
if (ride.Status != RideStatus.CancelRequested)
|
|
throw new InvalidOperationException("Ride is not in CancelRequested status");
|
|
|
|
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
|
|
|
|
cancelRequest.Status = CancelRequestStatus.Approved;
|
|
cancelRequest.ApprovedBy = approvedBy;
|
|
cancelRequest.ApprovedAt = DateTime.Now;
|
|
|
|
ride.Status = RideStatus.Cancelled;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
cancelRepo.Update(cancelRequest);
|
|
rideRepo.Update(ride);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToCancelResponse(cancelRequest);
|
|
}
|
|
|
|
public async Task<RideCancelRequestResponse> RejectCancelAsync(Guid cancelRequestId, Guid rejectedBy, CancellationToken ct = default)
|
|
{
|
|
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
|
|
?? throw new KeyNotFoundException("Cancel request not found");
|
|
|
|
if (cancelRequest.Status != CancelRequestStatus.Pending)
|
|
throw new InvalidOperationException("Cancel request is not pending");
|
|
|
|
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
if (ride.Status != RideStatus.CancelRequested)
|
|
throw new InvalidOperationException("Ride is not in CancelRequested status");
|
|
|
|
cancelRequest.Status = CancelRequestStatus.Rejected;
|
|
cancelRequest.ApprovedBy = rejectedBy;
|
|
cancelRequest.ApprovedAt = DateTime.Now;
|
|
|
|
// Restore to previous status: Active if ActivatedAt is set, else Open
|
|
ride.Status = ride.ActivatedAt.HasValue ? RideStatus.Active : RideStatus.Open;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
cancelRepo.Update(cancelRequest);
|
|
rideRepo.Update(ride);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToCancelResponse(cancelRequest);
|
|
}
|
|
|
|
public async Task<RideCancelRequestResponse?> GetCancelRequestAsync(Guid rideId)
|
|
{
|
|
var cancelRequest = await cancelRepo.GetByRideIdAsync(rideId);
|
|
return cancelRequest is null ? null : ToCancelResponse(cancelRequest);
|
|
}
|
|
|
|
public async Task<BreakdownAssistResponse> GetBreakdownAssistAsync(Guid rideId)
|
|
{
|
|
var strandedRide = await rideRepo.GetByIdAsync(rideId)
|
|
?? throw new KeyNotFoundException("Ride not found");
|
|
|
|
var activeRides = await rideRepo.GetActiveRidesAsync(rideId);
|
|
var activeRideResponses = new List<BreakdownAssistRideResponse>();
|
|
|
|
foreach (var ride in activeRides)
|
|
{
|
|
var stockLevels = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle);
|
|
activeRideResponses.Add(new BreakdownAssistRideResponse(
|
|
ride.Id,
|
|
ride.FK_VehicleId,
|
|
ride.Vehicle?.NumberPlate ?? string.Empty,
|
|
ride.FK_DriverProfileId,
|
|
ride.DriverProfile?.User?.FullName ?? string.Empty,
|
|
ride.FK_RouteId,
|
|
ride.Route?.RouteName ?? string.Empty,
|
|
stockLevels.Select(b => new StockBalanceResponse(
|
|
b.FK_ItemId,
|
|
b.Item.Name,
|
|
b.LocationId,
|
|
b.LocationType,
|
|
b.Quantity,
|
|
b.UpdatedAt)).ToList()));
|
|
}
|
|
|
|
var openOrders = await rideRepo.GetActiveOrdersByRouteIdAsync(strandedRide.FK_RouteId);
|
|
|
|
return new BreakdownAssistResponse(
|
|
strandedRide.Id,
|
|
activeRideResponses,
|
|
openOrders.Select(o => new BreakdownAssistOrderResponse(
|
|
o.Id,
|
|
o.StoreId,
|
|
o.StoreName,
|
|
o.OrderDate,
|
|
o.TotalAmount)).ToList());
|
|
}
|
|
|
|
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
|
{
|
|
var driver = await driverRepo.GetByIdAsync(driverProfileId)
|
|
?? throw new InvalidOperationException("Driver profile not found");
|
|
|
|
if (!driver.IsActive)
|
|
throw new InvalidOperationException("Driver profile is inactive");
|
|
|
|
var config = await driverRepo.GetComplianceConfigAsync();
|
|
if (config is null) return;
|
|
|
|
var today = DateTime.Now.Date;
|
|
|
|
if (config.EnforceLicenseExpiry && driver.LicenseExpiry.Date <= today)
|
|
throw new InvalidOperationException("Driver license has expired");
|
|
|
|
if (config.EnforceMedicalClearance && driver.MedicalClearanceExpiry.Date <= today)
|
|
throw new InvalidOperationException("Driver medical clearance has expired");
|
|
}
|
|
|
|
private async Task EnsureVehicleStockClearedAsync(Guid vehicleId)
|
|
{
|
|
var balances = await stockRepo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle);
|
|
if (balances.Any(b => b.Quantity > 0))
|
|
throw new InvalidOperationException("All vehicle stock must be returned before this ride can be completed");
|
|
}
|
|
|
|
private static RideResponse ToResponse(DeliveryRun r) =>
|
|
new(r.Id, r.RideType, r.Status,
|
|
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
|
|
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
|
|
r.FK_RouteId, r.Route?.RouteName ?? string.Empty,
|
|
r.FK_SourceWarehouseId, r.SourceWarehouse?.Name ?? string.Empty,
|
|
r.Meterstart, r.Meterend,
|
|
r.StartedAt, r.ActivatedAt, r.ClosedAt, r.CreatedAt);
|
|
|
|
private static RideCancelRequestResponse ToCancelResponse(RideCancelRequest c) =>
|
|
new(c.Id, c.FK_RideId, c.TypeCode, c.Reason, c.RequestedBy, c.RequestedAt,
|
|
c.Status, c.ApprovedBy, c.ApprovedAt);
|
|
}
|