Refactor/main flow #60
@@ -0,0 +1,23 @@
|
||||
namespace Distribution.Features.Warehouse;
|
||||
|
||||
public interface IWarehouseDashboardRepository
|
||||
{
|
||||
Task<List<WarehouseBalanceRow>> GetBalanceRowsAsync(Guid warehouseId);
|
||||
Task<List<WarehouseMovementRow>> GetMovementsAsync(Guid warehouseId, DateTime from, DateTime toExclusive);
|
||||
Task<List<WarehouseLastOutboundRow>> GetLastOutboundAsync(Guid warehouseId);
|
||||
|
||||
Task<WarehouseGrnTotals> GetGrnTotalsAsync(Guid warehouseId, DateTime from, DateTime toExclusive);
|
||||
Task<List<DashboardGrnRow>> GetRecentGrnsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take);
|
||||
|
||||
Task<List<DashboardLoadingSheetRow>> GetRecentLoadingSheetsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take);
|
||||
Task<decimal> GetPendingDispatchUnitsAsync(Guid warehouseId);
|
||||
|
||||
Task<List<WarehouseVehicleStockRow>> GetVehicleStockAsync(Guid warehouseId, Guid? primaryVehicleId);
|
||||
Task<List<DashboardLinkedVehicle>> GetDefaultVehiclesAsync(Guid warehouseId, Guid? primaryVehicleId);
|
||||
|
||||
Task<int> GetOpenClaimCountAsync(Guid warehouseId);
|
||||
Task<List<DashboardClaimRow>> GetOpenClaimsAsync(Guid warehouseId, int take);
|
||||
|
||||
Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids);
|
||||
Task<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> ids);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace Distribution.Features.Warehouse;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/warehouses")]
|
||||
public class WarehouseController(WarehouseService service) : ControllerBase
|
||||
public class WarehouseController(WarehouseService service, WarehouseDashboardService dashboardService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
@@ -17,6 +17,16 @@ public class WarehouseController(WarehouseService 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, [FromQuery] int deadStockDays = 30)
|
||||
{
|
||||
var error = WarehouseDashboardService.Validate(from, to, deadStockDays);
|
||||
if (error is not null) return BadRequest(new { message = error });
|
||||
var result = await dashboardService.GetAsync(id, from, to, deadStockDays);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Warehouse;
|
||||
|
||||
// ── Repository projections ─────────────────────────────────────────────────
|
||||
|
||||
public record WarehouseBalanceRow(
|
||||
Guid ItemId, string Sku, string Name, string Category, string? Brand, string Uom,
|
||||
decimal? ReorderLevel, decimal? CostPrice, decimal? SalePrice,
|
||||
Guid? BatchId, string? BatchNumber, DateTime? ExpiryDate,
|
||||
decimal Quantity, DateTime UpdatedAt);
|
||||
|
||||
public record WarehouseMovementRow(
|
||||
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 WarehouseLastOutboundRow(Guid ItemId, DateTime LastOutboundAt);
|
||||
|
||||
public record WarehouseGrnTotals(int Count, int Accepted, int Rejected);
|
||||
|
||||
public record WarehouseVehicleStockRow(
|
||||
Guid VehicleId, string RegNumber, string NumberPlate, bool IsLinked, decimal Units, int Skus);
|
||||
|
||||
// ── Response ───────────────────────────────────────────────────────────────
|
||||
|
||||
public record DashboardPeriod(DateTime From, DateTime To);
|
||||
|
||||
public record DashboardKpis(
|
||||
decimal StockValueCost, decimal StockValueSale, decimal TotalUnits, int SkuCount,
|
||||
int LowStockCount, int OutOfStockCount, int NearExpiryCount, int ExpiredCount,
|
||||
decimal UnitsIn, decimal UnitsOut);
|
||||
|
||||
public record DashboardBatch(Guid BatchId, string? BatchNumber, DateTime? ExpiryDate, decimal Quantity);
|
||||
|
||||
public record DashboardStockRow(
|
||||
Guid ItemId, string Sku, string Name, string Category, string? Brand, string Uom,
|
||||
decimal Quantity, decimal? ReorderLevel, string Status,
|
||||
decimal CostValue, decimal SaleValue, DateTime UpdatedAt, List<DashboardBatch> Batches);
|
||||
|
||||
public record DashboardCategoryStock(string Category, decimal Units, decimal Value);
|
||||
|
||||
public record DashboardExpiringBatch(
|
||||
Guid ItemId, string ItemName, string? BatchNumber, DateTime ExpiryDate, decimal Quantity, string Status);
|
||||
|
||||
public record DashboardDailyPoint(string Date, decimal In, decimal Out);
|
||||
|
||||
public record DashboardMovementTypeRow(MovementType Type, string Direction, decimal Units, int Count);
|
||||
|
||||
public record DashboardLedgerRow(
|
||||
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 DashboardGrnRow(
|
||||
Guid Id, DateTime ReceivedAt, Guid? PurchaseOrderId, string? SupplierName,
|
||||
int AcceptedQuantity, int RejectedQuantity, decimal CostValue);
|
||||
|
||||
public record DashboardInbound(
|
||||
int GrnCount, int AcceptedUnits, int RejectedUnits, decimal RejectionRatePercent, List<DashboardGrnRow> RecentGrns);
|
||||
|
||||
public record DashboardLoadingSheetRow(
|
||||
Guid Id, Guid RideId, string VehiclePlate, LoadingSheetStatus Status,
|
||||
DateTime PopulatedAt, DateTime? ConfirmedAt, int Lines, decimal Units);
|
||||
|
||||
public record DashboardOutbound(
|
||||
decimal UnitsDispatched, decimal PendingDispatchUnits, List<DashboardLoadingSheetRow> RecentLoadingSheets);
|
||||
|
||||
public record DashboardVehicle(
|
||||
Guid VehicleId, string RegNumber, string NumberPlate, bool IsLinked, decimal Units, int Skus);
|
||||
|
||||
public record DashboardClaimRow(Guid Id, DateTime CreatedAt, string? SupplierName, string Details, decimal Units);
|
||||
|
||||
public record DashboardLosses(
|
||||
decimal PurchaseReturnUnits, decimal DamageUnits, decimal NegativeAdjustmentUnits,
|
||||
decimal PositiveAdjustmentUnits, int OpenClaimCount, List<DashboardClaimRow> OpenClaims);
|
||||
|
||||
public record DashboardTopMover(Guid ItemId, string Sku, string Name, decimal UnitsOut);
|
||||
|
||||
public record DashboardDeadStock(
|
||||
Guid ItemId, string Sku, string Name, decimal Quantity, decimal CostValue, DateTime? LastOutboundAt);
|
||||
|
||||
public record DashboardLinkedVehicle(Guid Id, string RegNumber, string NumberPlate, bool IsPrimary);
|
||||
|
||||
public record WarehouseDashboardResponse(
|
||||
WarehouseResponse Warehouse,
|
||||
List<DashboardLinkedVehicle> LinkedVehicles,
|
||||
DashboardPeriod Period,
|
||||
int DeadStockDays,
|
||||
DashboardKpis Kpis,
|
||||
List<DashboardStockRow> Stock,
|
||||
List<DashboardCategoryStock> StockByCategory,
|
||||
List<DashboardExpiringBatch> ExpiringBatches,
|
||||
List<DashboardDailyPoint> DailyMovement,
|
||||
List<DashboardMovementTypeRow> MovementByType,
|
||||
List<DashboardLedgerRow> Ledger,
|
||||
DashboardInbound Inbound,
|
||||
DashboardOutbound Outbound,
|
||||
List<DashboardVehicle> Vehicles,
|
||||
DashboardLosses Losses,
|
||||
List<DashboardTopMover> TopMovers,
|
||||
List<DashboardDeadStock> DeadStock);
|
||||
@@ -0,0 +1,160 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Warehouse;
|
||||
|
||||
public class WarehouseDashboardRepository(AppDBContext db) : IWarehouseDashboardRepository
|
||||
{
|
||||
public Task<List<WarehouseBalanceRow>> GetBalanceRowsAsync(Guid warehouseId) =>
|
||||
db.StockBalances.AsNoTracking()
|
||||
.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == warehouseId)
|
||||
.Select(b => new WarehouseBalanceRow(
|
||||
b.FK_ItemId, b.Item.Sku, b.Item.Name, b.Item.Category.Name,
|
||||
b.Item.Brand != null ? b.Item.Brand.Name : null, b.Item.BaseUom.Name,
|
||||
b.Item.ReorderLevel, b.Item.CostPrice, b.Item.SalePrice,
|
||||
b.FK_ItemBatchId,
|
||||
b.ItemBatch != null ? b.ItemBatch.BatchNumber : null,
|
||||
b.ItemBatch != null ? b.ItemBatch.ExpiryDate : null,
|
||||
b.Quantity, b.UpdatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<WarehouseMovementRow>> GetMovementsAsync(Guid warehouseId, DateTime from, DateTime toExclusive) =>
|
||||
db.StockMovements.AsNoTracking()
|
||||
.Where(m => (m.FromLocationId == warehouseId || m.ToLocationId == warehouseId)
|
||||
&& m.CreatedAt >= from && m.CreatedAt < toExclusive)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Select(m => new WarehouseMovementRow(
|
||||
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<WarehouseLastOutboundRow>> GetLastOutboundAsync(Guid warehouseId) =>
|
||||
db.StockMovements.AsNoTracking()
|
||||
.Where(m => m.FromLocationId == warehouseId && m.ToLocationId != warehouseId)
|
||||
.GroupBy(m => m.FK_ItemId)
|
||||
.Select(g => new WarehouseLastOutboundRow(g.Key, g.Max(x => x.CreatedAt)))
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<WarehouseGrnTotals> GetGrnTotalsAsync(Guid warehouseId, DateTime from, DateTime toExclusive)
|
||||
{
|
||||
var receipts = db.GoodsReceipts.AsNoTracking()
|
||||
.Where(g => g.FK_WarehouseId == warehouseId && g.ReceivedAt >= from && g.ReceivedAt < toExclusive);
|
||||
var count = await receipts.CountAsync();
|
||||
var accepted = await receipts.SelectMany(g => g.Items).SumAsync(i => (int?)i.AcceptedQuantity) ?? 0;
|
||||
var rejected = await receipts.SelectMany(g => g.Items).SumAsync(i => (int?)i.RejectedQuantity) ?? 0;
|
||||
return new WarehouseGrnTotals(count, accepted, rejected);
|
||||
}
|
||||
|
||||
public Task<List<DashboardGrnRow>> GetRecentGrnsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take) =>
|
||||
db.GoodsReceipts.AsNoTracking()
|
||||
.Where(g => g.FK_WarehouseId == warehouseId && g.ReceivedAt >= from && g.ReceivedAt < toExclusive)
|
||||
.OrderByDescending(g => g.ReceivedAt)
|
||||
.Take(take)
|
||||
.Select(g => new DashboardGrnRow(
|
||||
g.Id, g.ReceivedAt, g.FK_PurchaseOrderId,
|
||||
g.PurchaseOrder != null ? g.PurchaseOrder.Supplier.Name : (g.Supplier != null ? g.Supplier.Name : null),
|
||||
g.Items.Sum(i => i.AcceptedQuantity),
|
||||
g.Items.Sum(i => i.RejectedQuantity),
|
||||
g.Items.Sum(i => i.AcceptedQuantity * i.UnitCost)))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<DashboardLoadingSheetRow>> GetRecentLoadingSheetsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take) =>
|
||||
db.LoadingSheets.AsNoTracking()
|
||||
.Where(s => s.PopulatedAt >= from && s.PopulatedAt < toExclusive
|
||||
&& s.Items.Any(i => i.FK_WarehouseId == warehouseId))
|
||||
.OrderByDescending(s => s.PopulatedAt)
|
||||
.Take(take)
|
||||
.Select(s => new DashboardLoadingSheetRow(
|
||||
s.Id, s.FK_RideId, s.Ride.Vehicle.NumberPlate, s.Status, s.PopulatedAt, s.ConfirmedAt,
|
||||
s.Items.Count(i => i.FK_WarehouseId == warehouseId),
|
||||
s.Items.Where(i => i.FK_WarehouseId == warehouseId).Sum(i => i.QuantityDispatched)))
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<decimal> GetPendingDispatchUnitsAsync(Guid warehouseId) =>
|
||||
await db.LoadingSheetItems.AsNoTracking()
|
||||
.Where(i => i.FK_WarehouseId == warehouseId && i.LoadingSheet.Status == LoadingSheetStatus.Planned)
|
||||
.SumAsync(i => (decimal?)i.QuantityDispatched) ?? 0m;
|
||||
|
||||
public async Task<List<WarehouseVehicleStockRow>> GetVehicleStockAsync(Guid warehouseId, Guid? primaryVehicleId)
|
||||
{
|
||||
var loadedFromHere = db.LoadingSheetItems
|
||||
.Where(i => i.FK_WarehouseId == warehouseId)
|
||||
.Select(i => i.LoadingSheet.Ride.FK_VehicleId);
|
||||
|
||||
var vehicles = await db.Vehicles.AsNoTracking()
|
||||
.Where(v => v.IsActive
|
||||
&& (v.DefaultWarehouseId == warehouseId
|
||||
|| (primaryVehicleId != null && v.Id == primaryVehicleId)
|
||||
|| loadedFromHere.Contains(v.Id)))
|
||||
.Select(v => new
|
||||
{
|
||||
v.Id, v.RegNumber, v.NumberPlate,
|
||||
IsLinked = v.DefaultWarehouseId == warehouseId || (primaryVehicleId != null && v.Id == primaryVehicleId),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ids = vehicles.Select(v => v.Id).ToList();
|
||||
var balances = await db.StockBalances.AsNoTracking()
|
||||
.Where(b => b.LocationType == LocationType.Vehicle && ids.Contains(b.LocationId) && b.Quantity > 0)
|
||||
.GroupBy(b => b.LocationId)
|
||||
.Select(g => new { VehicleId = g.Key, Units = g.Sum(x => x.Quantity), Skus = g.Select(x => x.FK_ItemId).Distinct().Count() })
|
||||
.ToListAsync();
|
||||
|
||||
return vehicles
|
||||
.Select(v =>
|
||||
{
|
||||
var bal = balances.FirstOrDefault(b => b.VehicleId == v.Id);
|
||||
return new WarehouseVehicleStockRow(v.Id, v.RegNumber, v.NumberPlate, v.IsLinked, bal?.Units ?? 0m, bal?.Skus ?? 0);
|
||||
})
|
||||
.OrderByDescending(v => v.Units)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<List<DashboardLinkedVehicle>> GetDefaultVehiclesAsync(Guid warehouseId, Guid? primaryVehicleId) =>
|
||||
db.Vehicles.AsNoTracking()
|
||||
.Where(v => v.IsActive
|
||||
&& (v.DefaultWarehouseId == warehouseId || (primaryVehicleId != null && v.Id == primaryVehicleId)))
|
||||
.Select(v => new DashboardLinkedVehicle(
|
||||
v.Id, v.RegNumber, v.NumberPlate, primaryVehicleId != null && v.Id == primaryVehicleId))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<int> GetOpenClaimCountAsync(Guid warehouseId) =>
|
||||
db.SupplierClaims.AsNoTracking()
|
||||
.CountAsync(c => c.Status == SupplierClaimStatus.Open
|
||||
&& c.GoodsReceipt != null && c.GoodsReceipt.FK_WarehouseId == warehouseId);
|
||||
|
||||
public Task<List<DashboardClaimRow>> GetOpenClaimsAsync(Guid warehouseId, int take) =>
|
||||
db.SupplierClaims.AsNoTracking()
|
||||
.Where(c => c.Status == SupplierClaimStatus.Open
|
||||
&& c.GoodsReceipt != null && c.GoodsReceipt.FK_WarehouseId == warehouseId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(take)
|
||||
.Select(c => new DashboardClaimRow(
|
||||
c.Id, c.CreatedAt, c.PurchaseOrder.Supplier.Name, c.Details,
|
||||
c.Items.Sum(i => i.Quantity)))
|
||||
.ToListAsync();
|
||||
|
||||
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,229 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ using Distribution.Features.User;
|
||||
using Distribution.Features.Vehicle;
|
||||
using Distribution.Features.ProductReport;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.StockReport;
|
||||
using Distribution.Features.SysConfig;
|
||||
using Distribution.Features.Warehouse;
|
||||
using Distribution.Infra.Authorization;
|
||||
@@ -51,8 +52,10 @@ builder.Services.AddScoped<IUnitOfWork, EFUnitOfWork>();
|
||||
builder.Services.AddScoped<IRegionRepository, RegionRepository>();
|
||||
builder.Services.AddScoped<IRouteRepository, RouteRepository>();
|
||||
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
|
||||
builder.Services.AddScoped<IVehicleDashboardRepository, VehicleDashboardRepository>();
|
||||
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
|
||||
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
|
||||
builder.Services.AddScoped<IWarehouseDashboardRepository, WarehouseDashboardRepository>();
|
||||
builder.Services.AddScoped<IShopPORepository, ShopPORepository>();
|
||||
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
@@ -65,6 +68,8 @@ builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
||||
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
||||
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
||||
builder.Services.AddScoped<ProductReportService>();
|
||||
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
|
||||
builder.Services.AddScoped<StockReportService>();
|
||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
|
||||
builder.Services.AddScoped<IRideRepository, RideRepository>();
|
||||
@@ -85,8 +90,10 @@ builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
|
||||
builder.Services.AddScoped<RegionService>();
|
||||
builder.Services.AddScoped<RouteService>();
|
||||
builder.Services.AddScoped<VehicleService>();
|
||||
builder.Services.AddScoped<VehicleDashboardService>();
|
||||
builder.Services.AddScoped<StoreService>();
|
||||
builder.Services.AddScoped<WarehouseService>();
|
||||
builder.Services.AddScoped<WarehouseDashboardService>();
|
||||
builder.Services.AddScoped<ShopPOService>();
|
||||
builder.Services.AddScoped<InvoiceService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
@@ -98,6 +105,7 @@ builder.Services.AddScoped<UomService>();
|
||||
builder.Services.AddScoped<ItemService>();
|
||||
builder.Services.AddScoped<SyncService>();
|
||||
builder.Services.AddScoped<StockService>();
|
||||
builder.Services.AddScoped<StockMover>();
|
||||
builder.Services.AddScoped<PriceListService>();
|
||||
builder.Services.AddScoped<SupplierService>();
|
||||
builder.Services.AddScoped<DriverProfileService>();
|
||||
|
||||
Reference in New Issue
Block a user