using Distribution.Features.LoadingEvent; using Distribution.Features.Notifications; using Distribution.Features.Ride; using Distribution.Features.ShopPO; using Distribution.Features.Stock; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.DeliveryRide; public class DeliveryTaskService( IDeliveryTaskRepository repo, IRideRepository rideRepo, IShopPORepository shopPORepo, ILoadingSheetRepository loadingSheetRepo, StockMover mover, INotificationService notifications, IUnitOfWork uow) { private const string DeliveriesLink = "/operations/deliveries"; // Route owner + managers whose scope covers the store being delivered to. private static List DeliveryRecipients(Guid storeId) => [ RecipientRule.RouteOwner(ScopeKind.Store, storeId), RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId), ]; private static DeliveryTaskResponse ToResponse(DeliveryTask t) => new(t.Id, t.FK_RideId, t.FK_ShopPOId, t.ShopPO.FK_StoreId, t.ShopPO.Store?.StoreName ?? string.Empty, t.Sequence, t.Status, t.CustomerConfirmed, t.ConfirmedAt, t.Note, t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse( i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(), t.CreatedAt); public async Task> GenerateAsync(Guid rideId, CancellationToken ct = default) { var ride = await rideRepo.GetByIdAsync(rideId) ?? throw new KeyNotFoundException("Ride not found"); var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue); if (confirmedSheets.Count == 0) throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from"); var shopPOIds = confirmedSheets .SelectMany(s => s.Orders) .Select(o => o.FK_ShopPOId) .Distinct() .ToList(); var existingTasks = await repo.GetByRideIdAsync(rideId); var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1; var newTasks = new List(); foreach (var shopPOId in shopPOIds) { if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue; newTasks.Add(new DeliveryTask { FK_RideId = rideId, FK_ShopPOId = shopPOId, Sequence = nextSequence++, Status = DeliveryTaskStatus.Pending }); } if (newTasks.Count > 0) { await uow.BeginAsync(ct); try { foreach (var task in newTasks) await repo.AddAsync(task); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } } var saved = await repo.GetByRideIdAsync(rideId); return saved.Select(ToResponse).ToList(); } public async Task> GetByRideIdAsync(Guid rideId) { var tasks = await repo.GetByRideIdAsync(rideId); return tasks.Select(ToResponse).ToList(); } public async Task> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize) { var items = await repo.GetAllAsync(status, page, pageSize); var total = await repo.GetCountAsync(status); return new PagedResponse(page, pageSize, total, items.Select(ToResponse).ToList()); } public async Task GetByIdAsync(Guid id) { var task = await repo.GetByIdAsync(id); return task is null ? null : ToResponse(task); } public async Task SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default) { var task = await repo.GetByIdAsync(taskId) ?? throw new KeyNotFoundException("Delivery task not found"); if (task.Status != DeliveryTaskStatus.Pending) throw new InvalidOperationException("Quantities can only be set for a Pending task"); foreach (var input in request.Items) { var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId) ?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO"); if (input.QuantityFulfilled < 0) throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}"); if (input.QuantityFulfilled > shopPOItem.Quantity) throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}"); shopPOItem.QuantityFulfilled = input.QuantityFulfilled; } await uow.BeginAsync(ct); try { repo.Update(task); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(taskId); return ToResponse(saved!); } public async Task ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default) { var task = await repo.GetByIdAsync(taskId) ?? throw new KeyNotFoundException("Delivery task not found"); if (task.Status != DeliveryTaskStatus.Pending) throw new InvalidOperationException("Only a Pending task can be confirmed"); if (!task.ShopPO.Items.Any(i => i.QuantityFulfilled > 0)) throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero"); var ride = await rideRepo.GetByIdAsync(task.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); var vehicleId = ride.FK_VehicleId; bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity); task.CustomerConfirmed = true; task.ConfirmedAt = DateTime.Now; task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered; var shopPO = task.ShopPO; shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled; if (fullyDelivered) shopPO.FulfilledAt = DateTime.Now; await uow.BeginAsync(ct); try { foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0)) { var itemName = item.Item?.Name ?? item.FK_ItemId.ToString(); await mover.MoveAsync(item.FK_ItemId, itemName, vehicleId, LocationType.Vehicle, StockService.ExternalLocationId, LocationType.External, item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId); } repo.Update(task); shopPORepo.Update(shopPO); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } if (!fullyDelivered && shopPO.Store is not null) await notifications.NotifyAsync( NotificationTypes.DeliveryTaskPartiallyDelivered, DeliveryRecipients(shopPO.FK_StoreId), new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId), ct); var saved = await repo.GetByIdAsync(taskId); return ToResponse(saved!); } public async Task FailAsync(Guid taskId, string note, CancellationToken ct = default) { var task = await repo.GetByIdAsync(taskId) ?? throw new KeyNotFoundException("Delivery task not found"); if (task.Status != DeliveryTaskStatus.Pending) throw new InvalidOperationException("Only a Pending task can be failed"); task.Status = DeliveryTaskStatus.Failed; task.Note = note; await uow.BeginAsync(ct); try { repo.Update(task); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } if (task.ShopPO?.Store is not null) await notifications.NotifyAsync( NotificationTypes.DeliveryTaskFailed, DeliveryRecipients(task.ShopPO.FK_StoreId), new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink), ct); return ToResponse(task); } }