207 lines
11 KiB
C#
207 lines
11 KiB
C#
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",
|
|
};
|
|
}
|
|
}
|