mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 10:12:09 +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);
|
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]
|
[Fact]
|
||||||
public async Task GetStatsAsync_MissingScopeFailsClosed()
|
public async Task GetStatsAsync_MissingScopeFailsClosed()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
open = stats.Open,
|
open = stats.Open,
|
||||||
notDispatched = stats.NotDispatched,
|
notDispatched = stats.NotDispatched,
|
||||||
completed = stats.Completed,
|
completed = stats.Completed,
|
||||||
|
scheduledTomorrow = stats.ScheduledTomorrow,
|
||||||
|
pendingUplifts = stats.PendingUplifts,
|
||||||
|
avetaPending = stats.AvetaPending,
|
||||||
breakdown = stats.Breakdown,
|
breakdown = stats.Breakdown,
|
||||||
dueCount = stats.DueCount,
|
dueCount = stats.DueCount,
|
||||||
completedDueCount = stats.CompletedDueCount,
|
completedDueCount = stats.CompletedDueCount,
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,70 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.ToListAsync(cancellationToken);
|
.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(
|
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||||
int? accountId,
|
int? accountId,
|
||||||
string? dispatcherId,
|
string? dispatcherId,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int? accountId,
|
int? accountId,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||||
int? accountId,
|
int? accountId,
|
||||||
string? dispatcherId,
|
string? dispatcherId,
|
||||||
|
|
@ -53,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
public int NotDispatched { get; set; }
|
public int NotDispatched { get; set; }
|
||||||
public int Completed { 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 Open { get; set; }
|
||||||
public int NotDispatched { get; set; }
|
public int NotDispatched { get; set; }
|
||||||
public int Completed { 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 DashboardBreakdownDTO Breakdown { get; set; } = new();
|
||||||
public int DueCount { get; set; }
|
public int DueCount { get; set; }
|
||||||
public int CompletedDueCount { get; set; }
|
public int CompletedDueCount { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||||
|
|
||||||
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
||||||
|
var kpis = await _dataService.GetKpiCountsAsync(
|
||||||
|
accountId,
|
||||||
|
dispatcherId,
|
||||||
|
DashboardBusinessTime.Today(),
|
||||||
|
cancellationToken);
|
||||||
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||||
accountId,
|
accountId,
|
||||||
dispatcherId,
|
dispatcherId,
|
||||||
|
|
@ -51,6 +56,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
Open = useMetrics ? metrics.Open : counts.Open,
|
Open = useMetrics ? metrics.Open : counts.Open,
|
||||||
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
|
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
|
||||||
Completed = useMetrics ? metrics.Completed : counts.Completed,
|
Completed = useMetrics ? metrics.Completed : counts.Completed,
|
||||||
|
ScheduledTomorrow = kpis.ScheduledTomorrow,
|
||||||
|
PendingUplifts = kpis.PendingUplifts,
|
||||||
|
AvetaPending = kpis.AvetaPending,
|
||||||
Breakdown = metrics.Breakdown,
|
Breakdown = metrics.Breakdown,
|
||||||
DueCount = metrics.DueCount,
|
DueCount = metrics.DueCount,
|
||||||
CompletedDueCount = metrics.CompletedDueCount,
|
CompletedDueCount = metrics.CompletedDueCount,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue