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