Files
ERP_Distribution/Distribution/Features/Warehouse/WarehouseDashboardService.cs
T

230 lines
12 KiB
C#

using Distribution.Models;
namespace Distribution.Features.Warehouse;
public class WarehouseDashboardService(IWarehouseRepository warehouseRepo, IWarehouseDashboardRepository repo)
{
private const int NearExpiryDays = 30;
private const int LedgerSize = 50;
private const int RecentDocumentCount = 8;
private const int TopListSize = 5;
private const int DeadStockListSize = 10;
private const int MaxPeriodDays = 366;
public static string? Validate(DateTime? from, DateTime? to, int deadStockDays)
{
if (deadStockDays < 1 || deadStockDays > 365) return "deadStockDays must be between 1 and 365.";
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 warehouse lookup, this also serves inactive warehouses so their history stays viewable.</summary>
public async Task<WarehouseDashboardResponse?> GetAsync(Guid id, DateTime? from, DateTime? to, int deadStockDays)
{
var warehouse = await warehouseRepo.GetByIdAsync(id);
if (warehouse is null) return null;
var now = DateTime.Now;
var today = now.Date;
var toDate = (to ?? today).Date;
var fromDate = (from ?? toDate.AddDays(-29)).Date;
var toExclusive = toDate.AddDays(1);
var balances = await repo.GetBalanceRowsAsync(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 lastOutbound = (await repo.GetLastOutboundAsync(id)).ToDictionary(r => r.ItemId, r => r.LastOutboundAt);
// ── Stock on hand ──────────────────────────────────────────────────
var stock = balances
.GroupBy(b => b.ItemId)
.Select(g => BuildStockRow(g.ToList()))
.OrderBy(r => r.Name)
.ToList();
var stockByCategory = stock
.Where(r => r.Quantity > 0)
.GroupBy(r => r.Category)
.Select(g => new DashboardCategoryStock(g.Key, g.Sum(r => r.Quantity), g.Sum(r => r.CostValue)))
.OrderByDescending(c => c.Value)
.ToList();
var itemNames = stock.ToDictionary(r => r.ItemId, r => r.Name);
var expiring = balances
.Where(b => b.Quantity > 0 && b.ExpiryDate.HasValue && b.BatchId.HasValue
&& b.ExpiryDate.Value.Date <= today.AddDays(NearExpiryDays))
.GroupBy(b => (b.ItemId, b.BatchId))
.Select(g =>
{
var first = g.First();
var expiry = first.ExpiryDate!.Value;
return new DashboardExpiringBatch(
first.ItemId, itemNames.GetValueOrDefault(first.ItemId, first.Name), first.BatchNumber,
expiry, g.Sum(x => x.Quantity), expiry.Date < today ? "Expired" : "NearExpiry");
})
.OrderBy(b => b.ExpiryDate)
.ToList();
// ── Movements ──────────────────────────────────────────────────────
var unitsIn = movements.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity);
var unitsOut = movements.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity);
var perDay = movements
.GroupBy(x => x.Row.CreatedAt.Date)
.ToDictionary(g => g.Key, g => (
In: g.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity),
Out: g.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity)));
var daily = new List<DashboardDailyPoint>();
for (var d = fromDate; d <= toDate; d = d.AddDays(1))
{
perDay.TryGetValue(d, out var p);
daily.Add(new DashboardDailyPoint(d.ToString("yyyy-MM-dd"), p.In, p.Out));
}
var byType = movements
.GroupBy(x => (x.Row.Type, x.Direction))
.Select(g => new DashboardMovementTypeRow(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 DashboardLedgerRow(
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();
// ── Inbound (GRN) ──────────────────────────────────────────────────
var grnTotals = await repo.GetGrnTotalsAsync(id, fromDate, toExclusive);
var received = grnTotals.Accepted + grnTotals.Rejected;
var inbound = new DashboardInbound(
grnTotals.Count, grnTotals.Accepted, grnTotals.Rejected,
received == 0 ? 0m : Math.Round(grnTotals.Rejected * 100m / received, 1),
await repo.GetRecentGrnsAsync(id, fromDate, toExclusive, RecentDocumentCount));
// ── Outbound (loading sheets) ──────────────────────────────────────
var outbound = new DashboardOutbound(
movements.Where(x => x.Row.Type == MovementType.Load && x.Direction == "Out").Sum(x => x.Row.Quantity),
await repo.GetPendingDispatchUnitsAsync(id),
await repo.GetRecentLoadingSheetsAsync(id, fromDate, toExclusive, RecentDocumentCount));
// ── Vehicles ───────────────────────────────────────────────────────
var vehicleStock = (await repo.GetVehicleStockAsync(id, warehouse.VehicleId))
.Select(v => new DashboardVehicle(v.VehicleId, v.RegNumber, v.NumberPlate, v.IsLinked, v.Units, v.Skus))
.ToList();
var linkedVehicles = await repo.GetDefaultVehiclesAsync(id, warehouse.VehicleId);
// ── Returns and losses ─────────────────────────────────────────────
decimal Units(MovementType type, string direction) =>
movements.Where(x => x.Row.Type == type && x.Direction == direction).Sum(x => x.Row.Quantity);
var losses = new DashboardLosses(
Units(MovementType.PurchaseReturn, "Out"), Units(MovementType.Damage, "Out"),
Units(MovementType.Adjustment, "Out"), Units(MovementType.Adjustment, "In"),
await repo.GetOpenClaimCountAsync(id),
await repo.GetOpenClaimsAsync(id, RecentDocumentCount));
// ── Top movers and dead stock ──────────────────────────────────────
var topMovers = movements
.Where(x => x.Row.Type == MovementType.Load && x.Direction == "Out")
.GroupBy(x => x.Row.ItemId)
.Select(g => new DashboardTopMover(g.Key, g.First().Row.Sku, g.First().Row.ItemName, g.Sum(x => x.Row.Quantity)))
.OrderByDescending(m => m.UnitsOut)
.Take(TopListSize)
.ToList();
var deadCutoff = now.AddDays(-deadStockDays);
var deadStock = stock
.Where(r => r.Quantity > 0 && r.UpdatedAt < deadCutoff
&& (!lastOutbound.TryGetValue(r.ItemId, out var last) || last < deadCutoff))
.OrderByDescending(r => r.CostValue)
.Take(DeadStockListSize)
.Select(r => new DashboardDeadStock(
r.ItemId, r.Sku, r.Name, r.Quantity, r.CostValue,
lastOutbound.TryGetValue(r.ItemId, out var last) ? last : null))
.ToList();
var kpis = new DashboardKpis(
stock.Sum(r => r.CostValue), stock.Sum(r => r.SaleValue),
stock.Where(r => r.Quantity > 0).Sum(r => r.Quantity), stock.Count(r => r.Quantity > 0),
stock.Count(r => r.Status == "Low"), stock.Count(r => r.Status == "Out"),
expiring.Count(b => b.Status == "NearExpiry"), expiring.Count(b => b.Status == "Expired"),
unitsIn, unitsOut);
return new WarehouseDashboardResponse(
new WarehouseResponse(warehouse.Id, warehouse.Name, warehouse.Location, warehouse.Type,
warehouse.VehicleId, warehouse.IsActive, warehouse.CreatedAt),
linkedVehicles, new DashboardPeriod(fromDate, toDate), deadStockDays, kpis,
stock, stockByCategory, expiring, daily, byType, ledger,
inbound, outbound, vehicleStock, losses, topMovers, deadStock);
}
private static DashboardStockRow BuildStockRow(List<WarehouseBalanceRow> rows)
{
var first = rows[0];
var quantity = rows.Sum(r => r.Quantity);
var billable = Math.Max(quantity, 0m);
var status = quantity <= 0 ? "Out"
: first.ReorderLevel.HasValue && quantity < first.ReorderLevel.Value ? "Low"
: "OK";
var batches = rows
.Where(r => r.BatchId.HasValue && r.Quantity > 0)
.GroupBy(r => r.BatchId!.Value)
.Select(g => new DashboardBatch(g.Key, g.First().BatchNumber, g.First().ExpiryDate, g.Sum(r => r.Quantity)))
.OrderBy(b => b.ExpiryDate ?? DateTime.MaxValue)
.ToList();
return new DashboardStockRow(
first.ItemId, first.Sku, first.Name, first.Category, first.Brand, first.Uom,
quantity, first.ReorderLevel, status,
billable * (first.CostPrice ?? 0m), billable * (first.SalePrice ?? 0m),
rows.Max(r => r.UpdatedAt), batches);
}
private static string? DirectionOf(WarehouseMovementRow m, Guid warehouseId) =>
m.ToLocationId == warehouseId && m.FromLocationId != warehouseId ? "In"
: m.FromLocationId == warehouseId && m.ToLocationId != warehouseId ? "Out"
: null;
private static string? ReferenceKind(MovementType type) => type switch
{
MovementType.PurchaseReceipt => "GRN",
MovementType.Load => "Loading Sheet",
MovementType.PurchaseReturn => "Purchase 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",
};
}
}