using Distribution.Features.Ride; using Distribution.Features.Stock; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.LoadingEvent; public class LoadingSheetService( ILoadingSheetRepository repo, IRideRepository rideRepo, IStockRepository stockRepo, IUnitOfWork uow) { public async Task> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) { var total = await repo.GetCountAsync(rideId, status, routeId, regionId); var items = await repo.GetAllAsync(rideId, status, routeId, regionId, page, pageSize); return new PagedResponse(page, pageSize, total, items.Select(ToResponse).ToList()); } public async Task GetByIdAsync(Guid id) { var sheet = await repo.GetByIdAsync(id); return sheet is null ? null : ToResponse(sheet); } public async Task CreateAsync(CreateLoadingSheetRequest request, Guid userId, CancellationToken ct = default) { var ride = await rideRepo.GetByIdAsync(request.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); if (ride.Status != RideStatus.Open) throw new InvalidOperationException("A loading sheet can only be created for an Open ride"); var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId); if (existing is not null) throw new InvalidOperationException("This ride already has a Planned loading sheet"); var sheet = new LoadingSheet { FK_RideId = ride.Id, Status = LoadingSheetStatus.Planned, PopulatedBy = userId, PopulatedAt = DateTime.Now }; await uow.BeginAsync(ct); try { await repo.AddAsync(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(sheet.Id); return ToResponse(saved!); } public async Task SetItemsAsync(Guid id, SetItemsRequest request, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.Planned) throw new InvalidOperationException("Items can only be changed while the sheet is Planned"); // The request is the full desired item list: a row matching (item, warehouse, // shop PO item) gets its quantity updated in place, a new combination is added, // and rows for combinations not present in the request are removed. await uow.BeginAsync(ct); try { var newItems = new List(); var keptItems = new List(); foreach (var input in request.Items) { var existing = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId && i.FK_WarehouseId == input.FK_WarehouseId && i.FK_ShopPOItemId == input.FK_ShopPOItemId); if (existing is not null) { existing.QuantityDispatched = input.Quantity; keptItems.Add(existing); } else newItems.Add(new LoadingSheetItem { FK_LoadingSheetId = sheet.Id, FK_ItemId = input.FK_ItemId, FK_WarehouseId = input.FK_WarehouseId, FK_ShopPOItemId = input.FK_ShopPOItemId, QuantityDispatched = input.Quantity }); } var removedItems = sheet.Items.Where(i => !keptItems.Contains(i)).ToList(); if (removedItems.Count > 0) repo.RemoveItems(removedItems); if (newItems.Count > 0) await repo.AddItemsAsync(newItems); var shopPOItemIds = keptItems.Select(i => i.FK_ShopPOItemId) .Concat(newItems.Select(i => i.FK_ShopPOItemId)) .Where(x => x.HasValue) .Select(x => x!.Value) .Distinct() .ToList(); var shopPOIds = shopPOItemIds.Count > 0 ? await repo.GetShopPOIdsByItemIdsAsync(shopPOItemIds) : new List(); await repo.ReplaceOrdersAsync(sheet.Id, shopPOIds); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(sheet.Id); return ToResponse(saved!); } public async Task ConfirmAsync(Guid id, Guid userId, CancellationToken ct = default) { var sheet = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Loading sheet not found"); if (sheet.Status != LoadingSheetStatus.Planned) throw new InvalidOperationException($"Cannot confirm a sheet in {sheet.Status} status"); if (!sheet.Items.Any(i => i.QuantityDispatched > 0)) throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero"); var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); await uow.BeginAsync(ct); try { await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, userId); sheet.Status = LoadingSheetStatus.Confirmed; sheet.ConfirmedAt = DateTime.Now; sheet.ConfirmedBy = userId; repo.Update(sheet); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(id); return ToResponse(saved!); } private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List items, Guid userId) { // Vehicle balances created during this confirm are only tracked, not yet saved, so a // later row for the same item would not find them via the repository and would try to // insert a duplicate (item, vehicle) balance. var createdVehicleBalances = new Dictionary(); foreach (var item in items.Where(i => i.QuantityDispatched > 0)) { var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId) ?? throw new InvalidOperationException( $"No stock found for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}'"); if (warehouseBalance.Quantity < item.QuantityDispatched) throw new InvalidOperationException( $"Insufficient stock for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}' " + $"(available {warehouseBalance.Quantity}, needed {item.QuantityDispatched})"); warehouseBalance.Quantity -= item.QuantityDispatched; warehouseBalance.UpdatedAt = DateTime.Now; stockRepo.UpdateBalance(warehouseBalance); var vehicleBalance = createdVehicleBalances.GetValueOrDefault(item.FK_ItemId) ?? await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId); if (vehicleBalance is null) { var created = new StockBalance { FK_ItemId = item.FK_ItemId, LocationId = vehicleId, LocationType = LocationType.Vehicle, Quantity = item.QuantityDispatched }; createdVehicleBalances[item.FK_ItemId] = created; await stockRepo.AddBalanceAsync(created); } else { vehicleBalance.Quantity += item.QuantityDispatched; vehicleBalance.UpdatedAt = DateTime.Now; stockRepo.UpdateBalance(vehicleBalance); } await stockRepo.AddMovementAsync(new StockMovement { FK_ItemId = item.FK_ItemId, FromLocationId = item.FK_WarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = vehicleId, ToLocationType = LocationType.Vehicle, Quantity = item.QuantityDispatched, Type = MovementType.Load, ReferenceId = sheetId, CreatedBy = userId }); } } private static LoadingSheetResponse ToResponse(LoadingSheet s) => new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt, s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt, s.Items.Select(i => new LoadingSheetItemResponse( i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty, i.QuantityDispatched, i.FK_ShopPOItemId, i.ShopPOItem?.FK_ShopPOId, i.ShopPOItem?.ShopPO?.FK_StoreId, i.ShopPOItem?.ShopPO?.Store?.StoreName)).ToList(), s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList()); }