Add stock report features r
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
namespace Distribution.Features.Vehicle;
|
||||
|
||||
public interface IVehicleDashboardRepository
|
||||
{
|
||||
Task<List<VehicleBalanceRow>> GetStockOnBoardAsync(Guid vehicleId);
|
||||
Task<List<VehicleMovementRow>> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
|
||||
|
||||
Task<List<VehicleRideRow>> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
|
||||
Task<VehicleRideRow?> GetOpenRideAsync(Guid vehicleId);
|
||||
Task<List<VehicleTaskRow>> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
|
||||
|
||||
Task<List<VehicleCollectionRow>> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
|
||||
Task<VehicleReturnTotals> GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
|
||||
|
||||
Task<List<VehicleDriverRow>> GetDriversAsync(IEnumerable<Guid> driverProfileIds);
|
||||
Task<VehicleNamedRef?> GetWarehouseRefAsync(Guid? warehouseId);
|
||||
Task<VehicleNamedRef?> GetMobileWarehouseAsync(Guid vehicleId);
|
||||
|
||||
Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids);
|
||||
Task<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> ids);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace Distribution.Features.Vehicle;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/vehicles")]
|
||||
public class VehicleController(VehicleService service) : ControllerBase
|
||||
public class VehicleController(VehicleService service, VehicleDashboardService dashboardService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
@@ -17,6 +17,15 @@ public class VehicleController(VehicleService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/dashboard")]
|
||||
public async Task<IActionResult> Dashboard(Guid id, [FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||
{
|
||||
var error = VehicleDashboardService.Validate(from, to);
|
||||
if (error is not null) return BadRequest(new { message = error });
|
||||
var result = await dashboardService.GetAsync(id, from, to);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Vehicle;
|
||||
|
||||
// ── Repository projections ─────────────────────────────────────────────────
|
||||
|
||||
public record VehicleBalanceRow(
|
||||
Guid ItemId, string Sku, string Name, string Category, string Uom,
|
||||
decimal? SalePrice, decimal Quantity, DateTime UpdatedAt);
|
||||
|
||||
public record VehicleMovementRow(
|
||||
Guid Id, Guid ItemId, string ItemName, string Sku,
|
||||
Guid? FromLocationId, LocationType? FromLocationType,
|
||||
Guid? ToLocationId, LocationType? ToLocationType,
|
||||
decimal Quantity, MovementType Type, string? ReasonCode, Guid? ReferenceId,
|
||||
DateTime CreatedAt, Guid CreatedBy);
|
||||
|
||||
public record VehicleRideRow(
|
||||
Guid Id, RideStatus Status, Guid DriverProfileId, string DriverName,
|
||||
int MeterStart, int MeterEnd, DateTime? StartedAt, DateTime? ClosedAt, DateTime CreatedAt,
|
||||
CancelRequestType? CancelType, string? CancelReason,
|
||||
int Tasks, int Delivered, int Failed);
|
||||
|
||||
public record VehicleTaskRow(
|
||||
Guid RideId, DeliveryTaskStatus Status, Guid StoreId, string StoreName,
|
||||
decimal DeliveredValue, DateTime Date);
|
||||
|
||||
public record VehicleCollectionRow(PaymentMethod Method, decimal Amount, int Count);
|
||||
|
||||
public record VehicleReturnTotals(int ReturnCount, decimal CreditValue, decimal Units);
|
||||
|
||||
public record VehicleDriverRow(
|
||||
Guid Id, string Name, string LicenseNumber, DateTime LicenseExpiry, DateTime MedicalClearanceExpiry, bool IsActive);
|
||||
|
||||
public record VehicleNamedRef(Guid Id, string Name);
|
||||
|
||||
// ── Response ───────────────────────────────────────────────────────────────
|
||||
|
||||
public record VehicleDashboardPeriod(DateTime From, DateTime To);
|
||||
|
||||
public record VehicleDashboardKpis(
|
||||
int Rides, int ClosedRides, int CancelledRides, decimal DistanceKm,
|
||||
int Deliveries, int DeliveredCount, int FailedCount, int PendingCount, decimal SuccessRatePercent,
|
||||
decimal DeliveredValue, decimal Collections,
|
||||
decimal StockOnBoardUnits, decimal StockOnBoardValue,
|
||||
decimal UnitsLoaded, decimal UnitsSold);
|
||||
|
||||
public record VehicleDashboardStockRow(
|
||||
Guid ItemId, string Sku, string Name, string Category, string Uom,
|
||||
decimal Quantity, decimal Value, DateTime UpdatedAt);
|
||||
|
||||
public record VehicleDashboardRide(
|
||||
Guid Id, RideStatus Status, string DriverName, DateTime? StartedAt, DateTime? ClosedAt, DateTime CreatedAt,
|
||||
decimal DistanceKm, int Tasks, int Delivered, int Failed, CancelRequestType? CancelType, string? CancelReason);
|
||||
|
||||
public record VehicleDashboardDailyDelivery(string Date, int Delivered, int Failed);
|
||||
|
||||
public record VehicleDashboardDailyMovement(string Date, decimal In, decimal Out);
|
||||
|
||||
public record VehicleDashboardMovementType(MovementType Type, string Direction, decimal Units, int Count);
|
||||
|
||||
public record VehicleDashboardLedgerRow(
|
||||
Guid Id, DateTime CreatedAt, Guid ItemId, string ItemName, string Sku,
|
||||
MovementType Type, string Direction, decimal Quantity,
|
||||
string FromName, string ToName, Guid? ReferenceId, string? ReferenceKind,
|
||||
string? ReasonCode, string CreatedByName);
|
||||
|
||||
public record VehicleDashboardCancelReason(CancelRequestType Type, int Count);
|
||||
|
||||
public record VehicleDashboardCollection(PaymentMethod Method, decimal Amount, int Count);
|
||||
|
||||
public record VehicleDashboardReturns(int ReturnCount, decimal CreditValue, decimal ReturnedUnits, decimal CustomerReturnUnitsOnVehicle);
|
||||
|
||||
public record VehicleDashboardTopStore(Guid StoreId, string StoreName, int Deliveries, decimal Value);
|
||||
|
||||
public record VehicleDashboardDriver(
|
||||
Guid Id, string Name, string LicenseNumber, DateTime LicenseExpiry, DateTime MedicalClearanceExpiry,
|
||||
bool IsActive, int Rides, decimal DistanceKm, DateTime? LastRideAt);
|
||||
|
||||
public record VehicleDashboardCurrentRide(
|
||||
Guid Id, string DriverName, DateTime? StartedAt, int MeterStart, int Tasks, int Delivered, int Failed);
|
||||
|
||||
public record VehicleDashboardResponse(
|
||||
VehicleResponse Vehicle,
|
||||
VehicleNamedRef? DefaultWarehouse,
|
||||
VehicleNamedRef? MobileWarehouse,
|
||||
VehicleDashboardCurrentRide? CurrentRide,
|
||||
VehicleDashboardPeriod Period,
|
||||
VehicleDashboardKpis Kpis,
|
||||
List<VehicleDashboardStockRow> StockOnBoard,
|
||||
List<VehicleDashboardRide> Rides,
|
||||
List<VehicleDashboardDailyDelivery> DailyDeliveries,
|
||||
List<VehicleDashboardDailyMovement> DailyMovement,
|
||||
List<VehicleDashboardMovementType> MovementByType,
|
||||
List<VehicleDashboardLedgerRow> Ledger,
|
||||
List<VehicleDashboardCancelReason> CancelReasons,
|
||||
List<VehicleDashboardCollection> CollectionsByMethod,
|
||||
VehicleDashboardReturns Returns,
|
||||
List<VehicleDashboardTopStore> TopStores,
|
||||
List<VehicleDashboardDriver> Drivers);
|
||||
@@ -0,0 +1,130 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Vehicle;
|
||||
|
||||
public class VehicleDashboardRepository(AppDBContext db) : IVehicleDashboardRepository
|
||||
{
|
||||
public Task<List<VehicleBalanceRow>> GetStockOnBoardAsync(Guid vehicleId) =>
|
||||
db.StockBalances.AsNoTracking()
|
||||
.Where(b => b.LocationType == LocationType.Vehicle && b.LocationId == vehicleId && b.Quantity > 0)
|
||||
.Select(b => new VehicleBalanceRow(
|
||||
b.FK_ItemId, b.Item.Sku, b.Item.Name, b.Item.Category.Name, b.Item.BaseUom.Name,
|
||||
b.Item.SalePrice, b.Quantity, b.UpdatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<VehicleMovementRow>> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) =>
|
||||
db.StockMovements.AsNoTracking()
|
||||
.Where(m => (m.FromLocationId == vehicleId || m.ToLocationId == vehicleId)
|
||||
&& m.CreatedAt >= from && m.CreatedAt < toExclusive)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Select(m => new VehicleMovementRow(
|
||||
m.Id, m.FK_ItemId, m.Item.Name, m.Item.Sku,
|
||||
m.FromLocationId, m.FromLocationType, m.ToLocationId, m.ToLocationType,
|
||||
m.Quantity, m.Type, m.ReasonCode, m.ReferenceId, m.CreatedAt, m.CreatedBy))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<VehicleRideRow>> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive) =>
|
||||
db.DeliveryRuns.AsNoTracking()
|
||||
.Where(r => r.FK_VehicleId == vehicleId
|
||||
&& (r.StartedAt ?? r.CreatedAt) >= from && (r.StartedAt ?? r.CreatedAt) < toExclusive)
|
||||
.OrderByDescending(r => r.StartedAt ?? r.CreatedAt)
|
||||
.Select(r => new VehicleRideRow(
|
||||
r.Id, r.Status, r.FK_DriverProfileId, r.DriverProfile.User.FullName,
|
||||
r.Meterstart, r.Meterend, r.StartedAt, r.ClosedAt, r.CreatedAt,
|
||||
r.CancelTypeCode, r.CancelReason,
|
||||
db.DeliveryTasks.Count(t => t.FK_RideId == r.Id),
|
||||
db.DeliveryTasks.Count(t => t.FK_RideId == r.Id
|
||||
&& (t.Status == DeliveryTaskStatus.Delivered || t.Status == DeliveryTaskStatus.PartiallyDelivered)),
|
||||
db.DeliveryTasks.Count(t => t.FK_RideId == r.Id && t.Status == DeliveryTaskStatus.Failed)))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<VehicleRideRow?> GetOpenRideAsync(Guid vehicleId) =>
|
||||
db.DeliveryRuns.AsNoTracking()
|
||||
.Where(r => r.FK_VehicleId == vehicleId && r.Status == RideStatus.Open)
|
||||
.Select(r => new VehicleRideRow(
|
||||
r.Id, r.Status, r.FK_DriverProfileId, r.DriverProfile.User.FullName,
|
||||
r.Meterstart, r.Meterend, r.StartedAt, r.ClosedAt, r.CreatedAt,
|
||||
r.CancelTypeCode, r.CancelReason,
|
||||
db.DeliveryTasks.Count(t => t.FK_RideId == r.Id),
|
||||
db.DeliveryTasks.Count(t => t.FK_RideId == r.Id
|
||||
&& (t.Status == DeliveryTaskStatus.Delivered || t.Status == DeliveryTaskStatus.PartiallyDelivered)),
|
||||
db.DeliveryTasks.Count(t => t.FK_RideId == r.Id && t.Status == DeliveryTaskStatus.Failed)))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public Task<List<VehicleTaskRow>> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive) =>
|
||||
db.DeliveryTasks.AsNoTracking()
|
||||
.Where(t => t.Ride.FK_VehicleId == vehicleId
|
||||
&& (t.Ride.StartedAt ?? t.Ride.CreatedAt) >= from && (t.Ride.StartedAt ?? t.Ride.CreatedAt) < toExclusive)
|
||||
.Select(t => new VehicleTaskRow(
|
||||
t.FK_RideId, t.Status, t.ShopPO.FK_StoreId, t.ShopPO.Store.StoreName,
|
||||
t.ShopPO.Items.Sum(i => i.QuantityFulfilled * i.UnitPrice),
|
||||
t.Ride.StartedAt ?? t.Ride.CreatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<VehicleCollectionRow>> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) =>
|
||||
db.Payments.AsNoTracking()
|
||||
.Where(p => p.CollectedAt >= from && p.CollectedAt < toExclusive
|
||||
&& db.DeliveryTasks.Any(t => t.Ride.FK_VehicleId == vehicleId && t.FK_ShopPOId == p.Invoice.FK_ShopPOId))
|
||||
.GroupBy(p => p.Method)
|
||||
.Select(g => new VehicleCollectionRow(g.Key, g.Sum(p => p.Amount), g.Count()))
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<VehicleReturnTotals> GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive)
|
||||
{
|
||||
var returns = db.SaleReturns.AsNoTracking()
|
||||
.Where(r => r.CreatedAt >= from && r.CreatedAt < toExclusive
|
||||
&& db.DeliveryTasks.Any(t => t.Ride.FK_VehicleId == vehicleId && t.FK_ShopPOId == r.FK_ShopPOId));
|
||||
var count = await returns.CountAsync();
|
||||
var credit = await returns.SumAsync(r => (decimal?)r.TotalCreditAmount) ?? 0m;
|
||||
var units = await returns.SelectMany(r => r.Items).SumAsync(i => (decimal?)i.Quantity) ?? 0m;
|
||||
return new VehicleReturnTotals(count, credit, units);
|
||||
}
|
||||
|
||||
public Task<List<VehicleDriverRow>> GetDriversAsync(IEnumerable<Guid> driverProfileIds)
|
||||
{
|
||||
var ids = driverProfileIds.Distinct().ToList();
|
||||
return db.DriverProfiles.AsNoTracking()
|
||||
.Where(d => ids.Contains(d.Id))
|
||||
.Select(d => new VehicleDriverRow(
|
||||
d.Id, d.User.FullName, d.LicenseNumber, d.LicenseExpiry, d.MedicalClearanceExpiry, d.IsActive))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<VehicleNamedRef?> GetWarehouseRefAsync(Guid? warehouseId) =>
|
||||
warehouseId is null
|
||||
? Task.FromResult<VehicleNamedRef?>(null)
|
||||
: db.Warehouses.AsNoTracking()
|
||||
.Where(w => w.Id == warehouseId)
|
||||
.Select(w => new VehicleNamedRef(w.Id, w.Name))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public Task<VehicleNamedRef?> GetMobileWarehouseAsync(Guid vehicleId) =>
|
||||
db.Warehouses.AsNoTracking()
|
||||
.Where(w => w.VehicleId == vehicleId)
|
||||
.Select(w => new VehicleNamedRef(w.Id, w.Name))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
var idList = ids.Distinct().ToList();
|
||||
var names = new Dictionary<Guid, string>();
|
||||
if (idList.Count == 0) return names;
|
||||
|
||||
foreach (var w in await db.Warehouses.AsNoTracking().Where(w => idList.Contains(w.Id)).Select(w => new { w.Id, w.Name }).ToListAsync())
|
||||
names[w.Id] = w.Name;
|
||||
foreach (var v in await db.Vehicles.AsNoTracking().Where(v => idList.Contains(v.Id)).Select(v => new { v.Id, v.RegNumber }).ToListAsync())
|
||||
names[v.Id] = v.RegNumber;
|
||||
return names;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
var idList = ids.Distinct().ToList();
|
||||
if (idList.Count == 0) return [];
|
||||
return await db.Users.AsNoTracking()
|
||||
.Where(u => idList.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, u => u.FullName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Vehicle;
|
||||
|
||||
public class VehicleDashboardService(IVehicleRepository vehicleRepo, IVehicleDashboardRepository repo)
|
||||
{
|
||||
private const int LedgerSize = 50;
|
||||
private const int RideListSize = 15;
|
||||
private const int TopListSize = 5;
|
||||
private const int MaxPeriodDays = 366;
|
||||
|
||||
public static string? Validate(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (from.HasValue && to.HasValue)
|
||||
{
|
||||
if (from.Value.Date > to.Value.Date) return "'from' must not be after 'to'.";
|
||||
if ((to.Value.Date - from.Value.Date).TotalDays >= MaxPeriodDays) return $"The period cannot exceed {MaxPeriodDays} days.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Unlike the standard vehicle lookup, this also serves inactive vehicles so their history stays viewable.</summary>
|
||||
public async Task<VehicleDashboardResponse?> GetAsync(Guid id, DateTime? from, DateTime? to)
|
||||
{
|
||||
var vehicle = await vehicleRepo.GetByIdAsync(id);
|
||||
if (vehicle is null) return null;
|
||||
|
||||
var toDate = (to ?? DateTime.Now).Date;
|
||||
var fromDate = (from ?? toDate.AddDays(-29)).Date;
|
||||
var toExclusive = toDate.AddDays(1);
|
||||
|
||||
var balances = await repo.GetStockOnBoardAsync(id);
|
||||
var movements = (await repo.GetMovementsAsync(id, fromDate, toExclusive))
|
||||
.Select(m => (Row: m, Direction: DirectionOf(m, id)))
|
||||
.Where(x => x.Direction is not null)
|
||||
.ToList();
|
||||
var rides = await repo.GetRidesAsync(id, fromDate, toExclusive);
|
||||
var tasks = await repo.GetTasksAsync(id, fromDate, toExclusive);
|
||||
var collections = await repo.GetCollectionsAsync(id, fromDate, toExclusive);
|
||||
var returnTotals = await repo.GetReturnTotalsAsync(id, fromDate, toExclusive);
|
||||
var openRide = await repo.GetOpenRideAsync(id);
|
||||
|
||||
// ── Stock on board ─────────────────────────────────────────────────
|
||||
var stock = balances
|
||||
.GroupBy(b => b.ItemId)
|
||||
.Select(g =>
|
||||
{
|
||||
var f = g.First();
|
||||
var qty = g.Sum(x => x.Quantity);
|
||||
return new VehicleDashboardStockRow(
|
||||
f.ItemId, f.Sku, f.Name, f.Category, f.Uom, qty, qty * (f.SalePrice ?? 0m), g.Max(x => x.UpdatedAt));
|
||||
})
|
||||
.OrderBy(r => r.Name)
|
||||
.ToList();
|
||||
|
||||
// ── Rides and deliveries ───────────────────────────────────────────
|
||||
static decimal Km(VehicleRideRow r) =>
|
||||
r.Status == RideStatus.Closed && r.MeterEnd > r.MeterStart ? r.MeterEnd - r.MeterStart : 0m;
|
||||
|
||||
var rideRows = rides.Take(RideListSize)
|
||||
.Select(r => new VehicleDashboardRide(
|
||||
r.Id, r.Status, r.DriverName, r.StartedAt, r.ClosedAt, r.CreatedAt,
|
||||
Km(r), r.Tasks, r.Delivered, r.Failed, r.CancelType, r.CancelReason))
|
||||
.ToList();
|
||||
|
||||
bool IsDelivered(VehicleTaskRow t) =>
|
||||
t.Status is DeliveryTaskStatus.Delivered or DeliveryTaskStatus.PartiallyDelivered;
|
||||
var delivered = tasks.Count(IsDelivered);
|
||||
var failed = tasks.Count(t => t.Status == DeliveryTaskStatus.Failed);
|
||||
var pending = tasks.Count(t => t.Status == DeliveryTaskStatus.Pending);
|
||||
var deliveredValue = tasks.Where(IsDelivered).Sum(t => t.DeliveredValue);
|
||||
|
||||
var dailyDeliveries = new List<VehicleDashboardDailyDelivery>();
|
||||
var dailyMovement = new List<VehicleDashboardDailyMovement>();
|
||||
var tasksByDay = tasks.GroupBy(t => t.Date.Date).ToDictionary(g => g.Key);
|
||||
var movesByDay = movements.GroupBy(x => x.Row.CreatedAt.Date).ToDictionary(g => g.Key);
|
||||
for (var d = fromDate; d <= toDate; d = d.AddDays(1))
|
||||
{
|
||||
var key = d.ToString("yyyy-MM-dd");
|
||||
tasksByDay.TryGetValue(d, out var dayTasks);
|
||||
movesByDay.TryGetValue(d, out var dayMoves);
|
||||
dailyDeliveries.Add(new VehicleDashboardDailyDelivery(
|
||||
key, dayTasks?.Count(IsDelivered) ?? 0, dayTasks?.Count(t => t.Status == DeliveryTaskStatus.Failed) ?? 0));
|
||||
dailyMovement.Add(new VehicleDashboardDailyMovement(
|
||||
key,
|
||||
dayMoves?.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity) ?? 0m,
|
||||
dayMoves?.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity) ?? 0m));
|
||||
}
|
||||
|
||||
var cancelReasons = rides
|
||||
.Where(r => r.Status == RideStatus.Cancelled && r.CancelType.HasValue)
|
||||
.GroupBy(r => r.CancelType!.Value)
|
||||
.Select(g => new VehicleDashboardCancelReason(g.Key, g.Count()))
|
||||
.OrderByDescending(c => c.Count)
|
||||
.ToList();
|
||||
|
||||
var topStores = tasks
|
||||
.Where(IsDelivered)
|
||||
.GroupBy(t => (t.StoreId, t.StoreName))
|
||||
.Select(g => new VehicleDashboardTopStore(g.Key.StoreId, g.Key.StoreName, g.Count(), g.Sum(t => t.DeliveredValue)))
|
||||
.OrderByDescending(s => s.Value)
|
||||
.Take(TopListSize)
|
||||
.ToList();
|
||||
|
||||
// ── Movements ──────────────────────────────────────────────────────
|
||||
var byType = movements
|
||||
.GroupBy(x => (x.Row.Type, x.Direction))
|
||||
.Select(g => new VehicleDashboardMovementType(g.Key.Type, g.Key.Direction!, g.Sum(x => x.Row.Quantity), g.Count()))
|
||||
.OrderByDescending(r => r.Units)
|
||||
.ToList();
|
||||
|
||||
var ledgerSource = movements.Take(LedgerSize).ToList();
|
||||
var locationIds = ledgerSource
|
||||
.SelectMany(x => new[]
|
||||
{
|
||||
(x.Row.FromLocationId, x.Row.FromLocationType),
|
||||
(x.Row.ToLocationId, x.Row.ToLocationType),
|
||||
})
|
||||
.Where(l => l.Item1.HasValue && l.Item2 is LocationType t && t != LocationType.External)
|
||||
.Select(l => l.Item1!.Value);
|
||||
var locationNames = await repo.GetLocationNamesAsync(locationIds);
|
||||
var userNames = await repo.GetUserNamesAsync(ledgerSource.Select(x => x.Row.CreatedBy));
|
||||
|
||||
var ledger = ledgerSource.Select(x => new VehicleDashboardLedgerRow(
|
||||
x.Row.Id, x.Row.CreatedAt, x.Row.ItemId, x.Row.ItemName, x.Row.Sku,
|
||||
x.Row.Type, x.Direction!, x.Row.Quantity,
|
||||
SideName(x.Row.Type, x.Row.FromLocationId, x.Row.FromLocationType, locationNames, isFrom: true),
|
||||
SideName(x.Row.Type, x.Row.ToLocationId, x.Row.ToLocationType, locationNames, isFrom: false),
|
||||
x.Row.ReferenceId, ReferenceKind(x.Row.Type),
|
||||
x.Row.ReasonCode, userNames.GetValueOrDefault(x.Row.CreatedBy, "—"))).ToList();
|
||||
|
||||
// ── Drivers ────────────────────────────────────────────────────────
|
||||
var driverProfiles = (await repo.GetDriversAsync(rides.Select(r => r.DriverProfileId)))
|
||||
.ToDictionary(d => d.Id);
|
||||
var drivers = rides
|
||||
.GroupBy(r => r.DriverProfileId)
|
||||
.Where(g => driverProfiles.ContainsKey(g.Key))
|
||||
.Select(g =>
|
||||
{
|
||||
var d = driverProfiles[g.Key];
|
||||
return new VehicleDashboardDriver(
|
||||
d.Id, d.Name, d.LicenseNumber, d.LicenseExpiry, d.MedicalClearanceExpiry, d.IsActive,
|
||||
g.Count(), g.Sum(Km), g.Max(r => r.StartedAt ?? r.CreatedAt));
|
||||
})
|
||||
.OrderByDescending(d => d.Rides)
|
||||
.ToList();
|
||||
|
||||
// ── Assemble ───────────────────────────────────────────────────────
|
||||
var settled = delivered + failed;
|
||||
var kpis = new VehicleDashboardKpis(
|
||||
rides.Count, rides.Count(r => r.Status == RideStatus.Closed), rides.Count(r => r.Status == RideStatus.Cancelled),
|
||||
rides.Sum(Km),
|
||||
tasks.Count, delivered, failed, pending,
|
||||
settled == 0 ? 0m : Math.Round(delivered * 100m / settled, 1),
|
||||
deliveredValue, collections.Sum(c => c.Amount),
|
||||
stock.Sum(r => r.Quantity), stock.Sum(r => r.Value),
|
||||
movements.Where(x => x.Row.Type == MovementType.Load && x.Direction == "In").Sum(x => x.Row.Quantity),
|
||||
movements.Where(x => x.Row.Type == MovementType.Sale && x.Direction == "Out").Sum(x => x.Row.Quantity));
|
||||
|
||||
var customerReturnUnits = movements
|
||||
.Where(x => x.Row.Type == MovementType.CustomerReturn && x.Direction == "In")
|
||||
.Sum(x => x.Row.Quantity);
|
||||
|
||||
return new VehicleDashboardResponse(
|
||||
new VehicleResponse(vehicle.Id, vehicle.RegNumber, vehicle.NumberPlate, vehicle.Type, vehicle.IsActive, vehicle.CreatedAt),
|
||||
await repo.GetWarehouseRefAsync(vehicle.DefaultWarehouseId),
|
||||
await repo.GetMobileWarehouseAsync(id),
|
||||
openRide is null ? null : new VehicleDashboardCurrentRide(
|
||||
openRide.Id, openRide.DriverName, openRide.StartedAt, openRide.MeterStart,
|
||||
openRide.Tasks, openRide.Delivered, openRide.Failed),
|
||||
new VehicleDashboardPeriod(fromDate, toDate),
|
||||
kpis, stock, rideRows, dailyDeliveries, dailyMovement, byType, ledger, cancelReasons,
|
||||
collections.Select(c => new VehicleDashboardCollection(c.Method, c.Amount, c.Count))
|
||||
.OrderByDescending(c => c.Amount).ToList(),
|
||||
new VehicleDashboardReturns(returnTotals.ReturnCount, returnTotals.CreditValue, returnTotals.Units, customerReturnUnits),
|
||||
topStores, drivers);
|
||||
}
|
||||
|
||||
private static string? DirectionOf(VehicleMovementRow m, Guid vehicleId) =>
|
||||
m.ToLocationId == vehicleId && m.FromLocationId != vehicleId ? "In"
|
||||
: m.FromLocationId == vehicleId && m.ToLocationId != vehicleId ? "Out"
|
||||
: null;
|
||||
|
||||
private static string? ReferenceKind(MovementType type) => type switch
|
||||
{
|
||||
MovementType.Load => "Loading Sheet",
|
||||
MovementType.Sale => "Order",
|
||||
MovementType.CustomerReturn => "Return",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string SideName(
|
||||
MovementType type, Guid? id, LocationType? locationType, Dictionary<Guid, string> names, bool isFrom)
|
||||
{
|
||||
if (id is not Guid locId || locationType is not LocationType lt) return "—";
|
||||
if (lt != LocationType.External) return names.GetValueOrDefault(locId, "—");
|
||||
return type switch
|
||||
{
|
||||
MovementType.Sale or MovementType.CustomerReturn => "Customer",
|
||||
MovementType.PurchaseReceipt or MovementType.PurchaseReturn => "Supplier",
|
||||
MovementType.Damage => "Written Off",
|
||||
MovementType.Adjustment => "Stock Adjustment",
|
||||
_ => isFrom ? "External Source" : "External Destination",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user