Files

208 lines
7.9 KiB
C#

using Distribution.Features.DeliveryRide;
using Distribution.Features.Notifications;
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Ride;
public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IDeliveryTaskRepository taskRepo,
StockMover mover,
INotificationService notifications,
ILogger<RideService> logger,
IUnitOfWork uow)
{
private const string RidesLink = "/operations/rides";
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
{
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, 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)
{
if (await rideRepo.HasOpenRideForVehicleAsync(request.VehicleId))
throw new InvalidOperationException("Vehicle already has an open ride");
await CheckDriverComplianceAsync(request.DriverProfileId);
var ride = new DeliveryRun
{
FK_VehicleId = request.VehicleId,
FK_DriverProfileId = request.DriverProfileId,
Status = RideStatus.Open,
Meterstart = request.Meterstart,
StartedAt = DateTime.Now
};
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> AssignDriverAsync(Guid rideId, Guid driverProfileId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException("Driver can only be changed while the ride is Open");
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; }
await notifications.NotifyAsync(
NotificationTypes.RideDriverAssigned,
[RecipientRule.Users(driver.FK_UserId)],
new NotificationArgs(null, "Ride", rideId, RidesLink),
ct);
var saved = await rideRepo.GetByIdAsync(rideId);
return ToResponse(saved!);
}
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, Guid userId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
if (request.Meterend < ride.Meterstart)
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})");
ride.Status = RideStatus.Closed;
ride.ClosedAt = DateTime.Now;
ride.Meterend = request.Meterend;
// Whatever wasn't delivered goes back to the warehouse it was loaded from.
await uow.BeginAsync(ct);
try
{
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideClosed");
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, Guid userId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
ride.Status = RideStatus.Cancelled;
ride.CancelTypeCode = request.TypeCode;
ride.CancelReason = request.Reason;
await uow.BeginAsync(ct);
try
{
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideCancelled");
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
await NotifyRideCancelledAsync(ride, request, userId, ct);
return ToResponse(ride);
}
// A ride has no route of its own; the people to tell are the owners and managers of the routes
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
{
try
{
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
var routeIds = tasks
.Where(t => t.ShopPO?.Store is not null)
.Select(t => t.ShopPO.Store.FK_RouteId)
.Distinct()
.ToList();
var recipients = new List<RecipientRule>();
foreach (var routeId in routeIds)
{
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
}
if (recipients.Count == 0) return;
await notifications.NotifyAsync(
NotificationTypes.RideCancelled,
recipients,
new NotificationArgs(
new { CancelType = request.TypeCode, Reason = request.Reason },
"Ride", ride.Id, RidesLink, userId),
ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
}
}
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 complianceRepo.GetAsync();
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 static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
r.Meterstart, r.Meterend,
r.StartedAt, r.ClosedAt, r.CancelTypeCode, r.CancelReason, r.CreatedAt);
}