mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
Merge pull request #138 from Sea-Haven-Industries/feat/ab/sh-353-dashboard-kpis-stacked
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-353: Expose dashboard KPI counts
This commit is contained in:
commit
6edcd2a463
7 changed files with 192 additions and 0 deletions
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class DashboardControllerTests
|
||||
{
|
||||
private static object Prop(object source, string name) =>
|
||||
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||
|
||||
private static DashboardController NewController(Mock<IDashboardService> service) =>
|
||||
new(service.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
// Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and
|
||||
// DashboardStatsDTO already carried these three counts; the regression this
|
||||
// pins is the controller's anonymous response silently dropping them, which
|
||||
// is what left the tiles with nothing to read.
|
||||
[Fact]
|
||||
public async Task GetStats_SerialisesKpiCountsOnWireObject()
|
||||
{
|
||||
var service = new Mock<IDashboardService>();
|
||||
service.Setup(s => s.GetStatsAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<DashboardStatsQueryDTO>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DashboardStatsDTO
|
||||
{
|
||||
ScheduledTomorrow = 3,
|
||||
PendingUplifts = 5,
|
||||
AvetaPending = 7
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetStats(
|
||||
new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
|
||||
var body = result.Should().BeOfType<OkObjectResult>().Subject.Value!;
|
||||
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
|
||||
((int)Prop(body, "pendingUplifts")).Should().Be(5);
|
||||
((int)Prop(body, "avetaPending")).Should().Be(7);
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +99,55 @@ public class DashboardServiceTests
|
|||
stats.Completed.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var today = DashboardBusinessTime.Today();
|
||||
var tomorrow = today.AddDays(1);
|
||||
|
||||
// Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow.
|
||||
ctx.workOrders.Add(new WorkOrder
|
||||
{
|
||||
AccountId = 1,
|
||||
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||
ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue)
|
||||
});
|
||||
|
||||
// Aveta-pending tile: Aveta-required order in the today/tomorrow window
|
||||
// with no Aveta attachment. Its Dispatch anchors the uplift requests below.
|
||||
var avetaOrder = new WorkOrder
|
||||
{
|
||||
AccountId = 1,
|
||||
AvetaRequired = true,
|
||||
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
|
||||
};
|
||||
ctx.workOrders.Add(avetaOrder);
|
||||
ctx.SaveChanges();
|
||||
|
||||
var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false };
|
||||
ctx.Set<Dispatch>().Add(dispatch);
|
||||
ctx.SaveChanges();
|
||||
|
||||
// Pending-uplifts tile: only Status == "Pending" is an outstanding uplift
|
||||
// awaiting an approval decision. This test pins the behaviour the code
|
||||
// ships today: a "ChangesRequested" request has been sent back to the
|
||||
// vendor, so it is NOT counted. Whether pendingUplifts should also include
|
||||
// ChangesRequested is an open product call, not a settled review decision.
|
||||
ctx.Set<DispatchUpliftRequest>().AddRange(
|
||||
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false },
|
||||
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false });
|
||||
ctx.SaveChanges();
|
||||
|
||||
var stats = await NewService(ctx).GetStatsAsync(
|
||||
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
|
||||
stats.ScheduledTomorrow.Should().Be(1);
|
||||
stats.AvetaPending.Should().Be(1);
|
||||
stats.PendingUplifts.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_MissingScopeFailsClosed()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
open = stats.Open,
|
||||
notDispatched = stats.NotDispatched,
|
||||
completed = stats.Completed,
|
||||
scheduledTomorrow = stats.ScheduledTomorrow,
|
||||
pendingUplifts = stats.PendingUplifts,
|
||||
avetaPending = stats.AvetaPending,
|
||||
breakdown = stats.Breakdown,
|
||||
dueCount = stats.DueCount,
|
||||
completedDueCount = stats.CompletedDueCount,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,70 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly today,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tomorrow = today.AddDays(1);
|
||||
var dayAfterTomorrow = tomorrow.AddDays(1);
|
||||
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId is int scopedAccountId)
|
||||
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
||||
if (dispatcherId != null)
|
||||
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
|
||||
|
||||
var scheduledTomorrow = await workOrders
|
||||
.Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue)
|
||||
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue))
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var avetaPending = await workOrders
|
||||
.Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue)
|
||||
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)
|
||||
&& w.AvetaRequired
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& (w.LifecycleStatus != null
|
||||
|| ((w.LegacyStatus ?? w.Status) != "Done"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Completed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Complete"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Closed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Canceled"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
|
||||
&& !w.workOrderAttachments!.Any(attachment =>
|
||||
(attachment.IsDeleted == null || attachment.IsDeleted == false)
|
||||
&& attachment.Category == WorkOrderMediaCategory.Aveta))
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var pendingUplifts = _context.DispatchUpliftRequests
|
||||
.AsNoTracking()
|
||||
.Where(request => request.Status == "Pending"
|
||||
&& (request.IsDeleted == null || request.IsDeleted == false)
|
||||
&& request.Dispatch != null
|
||||
&& (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false));
|
||||
if (accountId is int account)
|
||||
{
|
||||
pendingUplifts = pendingUplifts.Where(request =>
|
||||
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account)
|
||||
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
|
||||
link.WorkOrder != null && link.WorkOrder.AccountId == account));
|
||||
}
|
||||
if (dispatcherId != null)
|
||||
{
|
||||
pendingUplifts = pendingUplifts.Where(request =>
|
||||
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId)
|
||||
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
|
||||
link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId));
|
||||
}
|
||||
|
||||
return new DashboardKpiCounts(
|
||||
scheduledTomorrow,
|
||||
await pendingUplifts.CountAsync(cancellationToken),
|
||||
avetaPending);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly today,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
|
|
@ -53,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
public int NotDispatched { get; set; }
|
||||
public int Completed { get; set; }
|
||||
}
|
||||
|
||||
public sealed record DashboardKpiCounts(
|
||||
int ScheduledTomorrow,
|
||||
int PendingUplifts,
|
||||
int AvetaPending);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ namespace SeaHaven.Services.DTOs
|
|||
public int Open { get; set; }
|
||||
public int NotDispatched { get; set; }
|
||||
public int Completed { get; set; }
|
||||
public int ScheduledTomorrow { get; set; }
|
||||
public int PendingUplifts { get; set; }
|
||||
public int AvetaPending { get; set; }
|
||||
public DashboardBreakdownDTO Breakdown { get; set; } = new();
|
||||
public int DueCount { get; set; }
|
||||
public int CompletedDueCount { get; set; }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ namespace SeaHaven.Services.Implementation
|
|||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
|
||||
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
||||
var kpis = await _dataService.GetKpiCountsAsync(
|
||||
accountId,
|
||||
dispatcherId,
|
||||
DashboardBusinessTime.Today(),
|
||||
cancellationToken);
|
||||
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||
accountId,
|
||||
dispatcherId,
|
||||
|
|
@ -51,6 +56,9 @@ namespace SeaHaven.Services.Implementation
|
|||
Open = useMetrics ? metrics.Open : counts.Open,
|
||||
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
|
||||
Completed = useMetrics ? metrics.Completed : counts.Completed,
|
||||
ScheduledTomorrow = kpis.ScheduledTomorrow,
|
||||
PendingUplifts = kpis.PendingUplifts,
|
||||
AvetaPending = kpis.AvetaPending,
|
||||
Breakdown = metrics.Breakdown,
|
||||
DueCount = metrics.DueCount,
|
||||
CompletedDueCount = metrics.CompletedDueCount,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue