Merge branch 'dev' into feat/ab/sh-210-uplift-decisions

This commit is contained in:
Adam Moussa 2026-09-17 11:35:00 -04:00 • committed by GitHub
commit ef371b5917
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 347 additions and 34 deletions

View file

@ -270,11 +270,13 @@ public class DashboardServiceTests
1, 1,
CancellationToken.None); CancellationToken.None);
result.TotalDispatchers.Should().Be(1); result.TotalDispatchers.Should().Be(2);
result.Items.Should().ContainSingle(); result.Items.Select(item => item.DispatcherName)
result.Items[0].DispatcherName.Should().Be("Ada One"); .Should().ContainInOrder("Ada One", "Ben Two");
result.Items[0].TotalCount.Should().Be(2); result.Items[0].TotalCount.Should().Be(2);
result.Items[0].OpenCount.Should().Be(1); result.Items[0].OpenCount.Should().Be(1);
result.Items[1].TotalCount.Should().Be(0);
result.Items[1].OpenCount.Should().Be(0);
result.PageSize.Should().Be(10); result.PageSize.Should().Be(10);
var adminResult = await NewService(ctx).GetWorkloadAsync( var adminResult = await NewService(ctx).GetWorkloadAsync(
@ -444,6 +446,275 @@ public class DashboardServiceTests
result.Items[0].RescheduleRate.Should().Be(50); result.Items[0].RescheduleRate.Should().Be(50);
} }
[Fact]
public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" };
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
stats.Total.Should().Be(2);
stats.Open.Should().Be(2);
}
[Fact]
public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var service = NewService(ctx);
var selected = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
selected.Total.Should().Be(3);
selected.Open.Should().Be(3);
var mine = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "mine" },
CancellationToken.None);
mine.Total.Should().Be(1);
mine.Completed.Should().Be(1);
var all = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
all.Total.Should().Be(4);
}
[Fact]
public async Task GetStatsAsync_ManagerSelectionScopesStats()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "manager-1", "Manager"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
stats.Total.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_SchedulerSelectionScopesStats()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "scheduler-1", "Scheduler"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
stats.Total.Should().Be(1);
}
[Fact]
public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed()
{
using var ctx = NewContext();
var service = NewService(ctx);
var user = AccountUser(1, "tech-1", "Technician");
var statsAct = () => service.GetStatsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var statsException = await statsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
statsException.Which.Code.Should().Be("Forbidden");
var workloadAct = () => service.GetWorkloadAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var workloadException = await workloadAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
workloadException.Which.Code.Should().Be("Forbidden");
var performanceAct = () => service.GetPerformanceAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var performanceException = await performanceAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
performanceException.Which.Code.Should().Be("Forbidden");
var regionsAct = () => service.GetRegionsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var regionsException = await regionsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
regionsException.Which.Code.Should().Be("Forbidden");
var trendAct = () => service.GetTrendAsync(
user, new DashboardTrendQueryDTO(), CancellationToken.None);
var trendException = await trendAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
trendException.Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows()
{
using var ctx = NewContext();
ctx.Roles.Add(new IdentityRole
{
Id = "dispatcher-role",
Name = "Dispatcher",
NormalizedName = "DISPATCHER"
});
ctx.Users.AddRange(
new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" },
new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" });
ctx.UserRoles.AddRange(
new IdentityUserRole<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
new IdentityUserRole<string> { UserId = "dispatcher-2", RoleId = "dispatcher-role" });
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" },
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" });
ctx.SaveChanges();
var workload = await NewService(ctx).GetWorkloadAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
1,
CancellationToken.None);
workload.TotalDispatchers.Should().Be(2);
workload.Items.Should().HaveCount(2);
workload.Items[0].DispatcherId.Should().Be("dispatcher-1");
workload.Items[0].TotalCount.Should().Be(2);
workload.Items[0].OpenCount.Should().Be(2);
workload.Items[1].DispatcherId.Should().Be("dispatcher-2");
workload.Items[1].TotalCount.Should().Be(0);
workload.Items[1].OpenCount.Should().Be(0);
workload.Page.Should().Be(1);
workload.PageSize.Should().Be(10);
var performance = await NewService(ctx).GetPerformanceAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
1,
CancellationToken.None);
performance.TotalDispatchers.Should().Be(2);
performance.Items.Should().HaveCount(2);
performance.Items[0].DispatcherId.Should().Be("dispatcher-1");
performance.Items[0].AssignedCount.Should().Be(2);
performance.Items[1].DispatcherId.Should().Be("dispatcher-2");
performance.Items[1].AssignedCount.Should().Be(0);
performance.Items[1].CompletedCount.Should().Be(0);
}
[Fact]
public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts()
{
using var ctx = NewContext();
ctx.Roles.Add(new IdentityRole
{
Id = "dispatcher-role",
Name = "Dispatcher",
NormalizedName = "DISPATCHER"
});
ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" });
ctx.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = "dispatcher-role"
});
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled
});
ctx.SaveChanges();
var result = await NewService(ctx).GetPerformanceAsync(
AccountUser(1),
new DashboardStatsQueryDTO(),
1,
CancellationToken.None);
result.Items.Should().ContainSingle();
result.Items[0].AssignedCount.Should().Be(3);
result.Items[0].CompletedCount.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
},
new WorkOrder { AccountId = 1, Status = "In Progress" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Unparseable" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
stats.StatusDistribution.Should().HaveCount(4);
stats.StatusDistribution
.Select(bucket => (bucket.Status, bucket.Count))
.Should().BeEquivalentTo(new[]
{
(Status: "Completed", Count: 2),
(Status: "In Progress", Count: 1),
(Status: "Scheduled", Count: 1),
(Status: "Unknown", Count: 1)
});
stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5);
}
[Fact]
public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders()
{
using var ctx = NewContext();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
stats.StatusDistribution.Should().BeEmpty();
}
[Fact] [Fact]
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
{ {

View file

@ -37,7 +37,8 @@ namespace Api.SeaHavenIndustries.Controllers
dueCount = stats.DueCount, dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount, completedDueCount = stats.CompletedDueCount,
completionRate = stats.CompletionRate, completionRate = stats.CompletionRate,
averageResolutionDays = stats.AverageResolutionDays averageResolutionDays = stats.AverageResolutionDays,
statusDistribution = stats.StatusDistribution
}); });
} }

View file

@ -166,7 +166,6 @@ namespace SeaHaven.DataServices.Implementation
on userRole.RoleId equals role.Id on userRole.RoleId equals role.Id
where user.IsDeleted != true where user.IsDeleted != true
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER") && (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
&& (dispatcherId == null || user.Id == dispatcherId)
select new select new
{ {
user.Id, user.Id,
@ -178,6 +177,9 @@ namespace SeaHaven.DataServices.Implementation
if (accountId is int scopedAccountId) if (accountId is int scopedAccountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
if (dispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
if (dateFrom is DateOnly from && dateTo is DateOnly to) if (dateFrom is DateOnly from && dateTo is DateOnly to)
{ {
var fromDate = from.ToDateTime(TimeOnly.MinValue); var fromDate = from.ToDateTime(TimeOnly.MinValue);

View file

@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs
{ {
public DateOnly? DateFrom { get; set; } public DateOnly? DateFrom { get; set; }
public DateOnly? DateTo { get; set; } public DateOnly? DateTo { get; set; }
public string? DispatcherId { get; set; }
} }
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
@ -63,6 +64,8 @@ namespace SeaHaven.Services.DTOs
public string? Color { get; init; } public string? Color { get; init; }
public decimal CompletionRate { get; init; } public decimal CompletionRate { get; init; }
public decimal? AverageResolutionDays { get; init; } public decimal? AverageResolutionDays { get; init; }
public int AssignedCount { get; init; }
public int CompletedCount { get; init; }
} }
public sealed class DashboardRegionResponseDTO public sealed class DashboardRegionResponseDTO
@ -108,6 +111,13 @@ namespace SeaHaven.Services.DTOs
public int CompletedDueCount { get; set; } public int CompletedDueCount { get; set; }
public decimal CompletionRate { get; set; } public decimal CompletionRate { get; set; }
public decimal? AverageResolutionDays { get; set; } public decimal? AverageResolutionDays { get; set; }
public IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution { get; set; } = [];
}
public sealed class DashboardStatusBucketDTO
{
public string Status { get; init; } = string.Empty;
public int Count { get; init; }
} }
public sealed class DashboardBreakdownDTO public sealed class DashboardBreakdownDTO

View file

@ -61,9 +61,26 @@ public static class DashboardMetrics
dueCount, dueCount,
completedDueCount, completedDueCount,
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2), dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2)); resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2),
BuildStatusDistribution(workOrders));
} }
private static IReadOnlyList<DashboardStatusBucketDTO> BuildStatusDistribution(
IReadOnlyList<DashboardWorkOrder> workOrders)
=> workOrders
.GroupBy(workOrder => workOrder.LifecycleStatus
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus))
.Select(group => new DashboardStatusBucketDTO
{
Status = group.Key.HasValue
? LifecycleStatusMapper.ToFeLabel(group.Key.Value)
: "Unknown",
Count = group.Count()
})
.OrderByDescending(bucket => bucket.Count)
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
.ToList();
private static bool IsPastDue( private static bool IsPastDue(
DashboardWorkOrder workOrder, DashboardWorkOrder workOrder,
LifecycleStatus? status, LifecycleStatus? status,
@ -110,4 +127,5 @@ public sealed record DashboardMetricResult(
int DueCount, int DueCount,
int CompletedDueCount, int CompletedDueCount,
decimal CompletionRate, decimal CompletionRate,
decimal? AverageResolutionDays); decimal? AverageResolutionDays,
IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution);

View file

@ -28,11 +28,7 @@ namespace SeaHaven.Services.Implementation
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user); var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher") var dispatcherId = ResolveDispatcherScope(user, query);
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
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( var kpis = await _dataService.GetKpiCountsAsync(
@ -63,7 +59,8 @@ namespace SeaHaven.Services.Implementation
DueCount = metrics.DueCount, DueCount = metrics.DueCount,
CompletedDueCount = metrics.CompletedDueCount, CompletedDueCount = metrics.CompletedDueCount,
CompletionRate = metrics.CompletionRate, CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays AverageResolutionDays = metrics.AverageResolutionDays,
StatusDistribution = metrics.StatusDistribution
}; };
} }
@ -77,11 +74,7 @@ namespace SeaHaven.Services.Implementation
throw new ArgumentOutOfRangeException(nameof(page)); throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user); var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher") var dispatcherId = ResolveDispatcherScope(user, query);
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var rows = await _dataService.GetDispatcherWorkloadAsync( var rows = await _dataService.GetDispatcherWorkloadAsync(
accountId, accountId,
@ -123,11 +116,7 @@ namespace SeaHaven.Services.Implementation
throw new ArgumentOutOfRangeException(nameof(page)); throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user); var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher") var dispatcherId = ResolveDispatcherScope(user, query);
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var directory = await _dataService.GetDispatcherWorkloadAsync( var directory = await _dataService.GetDispatcherWorkloadAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
@ -145,7 +134,9 @@ namespace SeaHaven.Services.Implementation
DispatcherName = dispatcher.DispatcherName, DispatcherName = dispatcher.DispatcherName,
Color = dispatcher.Color, Color = dispatcher.Color,
CompletionRate = metrics.CompletionRate, CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays AverageResolutionDays = metrics.AverageResolutionDays,
AssignedCount = metrics.Total,
CompletedCount = metrics.Completed
}; };
}) })
.OrderByDescending(row => row.CompletionRate) .OrderByDescending(row => row.CompletionRate)
@ -169,11 +160,7 @@ namespace SeaHaven.Services.Implementation
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user); var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher") var dispatcherId = ResolveDispatcherScope(user, query);
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var workOrders = await _dataService.GetDashboardWorkOrdersAsync( var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
@ -196,11 +183,7 @@ namespace SeaHaven.Services.Implementation
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user); var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher") var dispatcherId = ResolveDispatcherScope(user, query);
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var today = DashboardBusinessTime.Today(); var today = DashboardBusinessTime.Today();
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
@ -404,5 +387,33 @@ namespace SeaHaven.Services.Implementation
TotalVendors = rows.Count TotalVendors = rows.Count
}; };
} }
private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query)
{
if (user.IsInRole("Dispatcher"))
{
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return dispatcherId;
}
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
{
var selected = query.DispatcherId;
if (string.IsNullOrWhiteSpace(selected))
return null;
if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase))
{
var identity = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(identity))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return identity;
}
return selected;
}
throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role.");
}
} }
} }