mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
Merge pull request #175 from Sea-Haven-Industries/feat/ab/sh-392-dashboard-unassigned
SH-392: count open unassigned work orders on the Dashboard
This commit is contained in:
commit
06eae2fb02
9 changed files with 414 additions and 2 deletions
|
|
@ -40,7 +40,8 @@ public class DashboardControllerTests
|
||||||
{
|
{
|
||||||
ScheduledTomorrow = 3,
|
ScheduledTomorrow = 3,
|
||||||
PendingUplifts = 5,
|
PendingUplifts = 5,
|
||||||
AvetaPending = 7
|
AvetaPending = 7,
|
||||||
|
Unassigned = 11
|
||||||
});
|
});
|
||||||
|
|
||||||
var result = await NewController(service).GetStats(
|
var result = await NewController(service).GetStats(
|
||||||
|
|
@ -50,5 +51,6 @@ public class DashboardControllerTests
|
||||||
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
|
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
|
||||||
((int)Prop(body, "pendingUplifts")).Should().Be(5);
|
((int)Prop(body, "pendingUplifts")).Should().Be(5);
|
||||||
((int)Prop(body, "avetaPending")).Should().Be(7);
|
((int)Prop(body, "avetaPending")).Should().Be(7);
|
||||||
|
((int)Prop(body, "unassigned")).Should().Be(11);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
249
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal file
249
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Dashboard "Unassigned" count is open work orders with no
|
||||||
|
/// dispatcher in the selected period, and it equals the Work Orders list the
|
||||||
|
/// tile drills into (GET /board/search, Dispatcher = Unassigned).
|
||||||
|
/// </summary>
|
||||||
|
public class DashboardUnassignedTests
|
||||||
|
{
|
||||||
|
private static readonly DateOnly From = new(2026, 9, 21);
|
||||||
|
private static readonly DateOnly To = new(2026, 9, 25);
|
||||||
|
|
||||||
|
private static readonly List<LifecycleStatus> OpenStatuses = Enum.GetValues<LifecycleStatus>()
|
||||||
|
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx)
|
||||||
|
=> new(new AccountDataService(ctx), new LocationDataService(ctx));
|
||||||
|
|
||||||
|
private static DashboardService NewDashboard(ApplicationDbContext ctx)
|
||||||
|
=> new(new DashboardDataService(ctx), Resolver(ctx));
|
||||||
|
|
||||||
|
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
|
||||||
|
=> new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx));
|
||||||
|
|
||||||
|
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1")
|
||||||
|
=> new(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||||
|
new Claim(ClaimTypes.Role, role)
|
||||||
|
}, "test"));
|
||||||
|
|
||||||
|
private static WorkOrder Wo(
|
||||||
|
int id,
|
||||||
|
DateTime? scheduled,
|
||||||
|
string? assignTo = null,
|
||||||
|
LifecycleStatus? status = LifecycleStatus.Scheduled,
|
||||||
|
DateOnly? targetWeek = null,
|
||||||
|
int accountId = 1,
|
||||||
|
string? legacyStatus = null,
|
||||||
|
string? statusText = null)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
AccountId = accountId,
|
||||||
|
InternalWONumber = $"2000000{id:0000}",
|
||||||
|
AssignTo = assignTo,
|
||||||
|
ScheduledDate = scheduled,
|
||||||
|
ScheduleWeekOnly = targetWeek.HasValue,
|
||||||
|
TargetWeek = targetWeek,
|
||||||
|
LifecycleStatus = status,
|
||||||
|
LegacyStatus = legacyStatus,
|
||||||
|
Status = statusText,
|
||||||
|
istemplate = false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rows written by legacy create paths, which set Status but no LifecycleStatus.
|
||||||
|
private static void SeedLegacy(ApplicationDbContext ctx)
|
||||||
|
{
|
||||||
|
var inRange = new DateTime(2026, 9, 22);
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
Wo(13, inRange, status: null, statusText: "Open"), // counted
|
||||||
|
Wo(14, inRange, status: null, statusText: "On Hold"), // counted: Pending
|
||||||
|
Wo(15, inRange, status: null), // counted: no status at all
|
||||||
|
Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins
|
||||||
|
Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled
|
||||||
|
Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open"), // assigned
|
||||||
|
Wo(19, inRange, status: null, legacyStatus: "Open", statusText: "Done")); // counted: LegacyStatus wins
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Seed(ApplicationDbContext ctx)
|
||||||
|
{
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
Wo(1, new DateTime(2026, 9, 22)), // counted
|
||||||
|
Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee
|
||||||
|
Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range
|
||||||
|
Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned
|
||||||
|
Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed),
|
||||||
|
Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled),
|
||||||
|
Wo(7, new DateTime(2026, 8, 4)), // out of range
|
||||||
|
Wo(8, null, status: LifecycleStatus.Incomplete), // undated
|
||||||
|
Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant
|
||||||
|
Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx);
|
||||||
|
|
||||||
|
var stats = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Unassigned.Should().Be(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx);
|
||||||
|
|
||||||
|
var stats = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
// 1, 2, 3, 7 (out of any week but open) and 8 (undated).
|
||||||
|
stats.Unassigned.Should().Be(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_CountsOpenLegacyRowsWithNoLifecycleStatus()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx);
|
||||||
|
SeedLegacy(ctx);
|
||||||
|
|
||||||
|
var inRange = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
|
||||||
|
var allTime = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
// 1, 2, 3 plus legacy 13, 14, 15 and 19.
|
||||||
|
inRange.Unassigned.Should().Be(7);
|
||||||
|
allTime.Unassigned.Should().Be(9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardSearch_StatusFilter_MatchesLegacyRowsByTheirLegacyStatus()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var inRange = new DateTime(2026, 9, 22);
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
Wo(1, inRange),
|
||||||
|
Wo(2, inRange, status: null, statusText: "scheduled"),
|
||||||
|
Wo(3, inRange, status: null, statusText: "Open"),
|
||||||
|
Wo(4, inRange, status: null, statusText: "Legacy Mystery"),
|
||||||
|
Wo(5, inRange, status: null));
|
||||||
|
ctx.SaveChanges();
|
||||||
|
var user = AccountUser(1);
|
||||||
|
|
||||||
|
async Task<IEnumerable<int>> Ids(LifecycleStatus status)
|
||||||
|
=> (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = From,
|
||||||
|
DateTo = To,
|
||||||
|
Statuses = new List<LifecycleStatus> { status },
|
||||||
|
PageSize = 200
|
||||||
|
}, user, "admin-1")).Items.Select(row => row.Id);
|
||||||
|
|
||||||
|
(await Ids(LifecycleStatus.Scheduled)).Should().BeEquivalentTo(new[] { 1, 2 });
|
||||||
|
// Unknown or missing legacy text reads as Incomplete, as the Phase0 backfill set it.
|
||||||
|
(await Ids(LifecycleStatus.Incomplete)).Should().BeEquivalentTo(new[] { 3, 4, 5 });
|
||||||
|
(await Ids(LifecycleStatus.Completed)).Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx);
|
||||||
|
|
||||||
|
var accountTwo = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
|
||||||
|
|
||||||
|
accountTwo.Unassigned.Should().Be(2);
|
||||||
|
accountTwoInRange.Unassigned.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx);
|
||||||
|
|
||||||
|
var dispatcher = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1, role: "Dispatcher", userId: "disp-1"),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
CancellationToken.None);
|
||||||
|
var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1),
|
||||||
|
new DashboardStatsQueryDTO { DispatcherId = "disp-1" },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
dispatcher.Unassigned.Should().Be(0);
|
||||||
|
adminPickingDispatcher.Unassigned.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange)
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx);
|
||||||
|
SeedLegacy(ctx);
|
||||||
|
// Dated outside the board's all-weeks window: neither side may count them.
|
||||||
|
ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1)));
|
||||||
|
ctx.SaveChanges();
|
||||||
|
var user = AccountUser(1);
|
||||||
|
|
||||||
|
var stats = await NewDashboard(ctx).GetStatsAsync(
|
||||||
|
user,
|
||||||
|
withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// What the Work Orders board sends for the tile's drill-down link.
|
||||||
|
var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = withRange ? From : new DateOnly(2000, 1, 1),
|
||||||
|
DateTo = withRange ? To : new DateOnly(2099, 12, 31),
|
||||||
|
IncludeDateless = !withRange,
|
||||||
|
Dispatchers = new List<string> { "__unassigned__" },
|
||||||
|
Statuses = OpenStatuses,
|
||||||
|
PageSize = 200
|
||||||
|
}, user, "admin-1");
|
||||||
|
|
||||||
|
stats.Unassigned.Should().Be(list.TotalCount);
|
||||||
|
var listed = list.Items.Select(row => row.Id).ToList();
|
||||||
|
listed.Should().Contain(new[] { 13, 14, 15, 19 });
|
||||||
|
listed.Should().NotContain(new[] { 16, 17, 18 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
scheduledTomorrow = stats.ScheduledTomorrow,
|
scheduledTomorrow = stats.ScheduledTomorrow,
|
||||||
pendingUplifts = stats.PendingUplifts,
|
pendingUplifts = stats.PendingUplifts,
|
||||||
avetaPending = stats.AvetaPending,
|
avetaPending = stats.AvetaPending,
|
||||||
|
unassigned = stats.Unassigned,
|
||||||
breakdown = stats.Breakdown,
|
breakdown = stats.Breakdown,
|
||||||
dueCount = stats.DueCount,
|
dueCount = stats.DueCount,
|
||||||
completedDueCount = stats.CompletedDueCount,
|
completedDueCount = stats.CompletedDueCount,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
{
|
{
|
||||||
public static class WorkOrderBoardQueryFilters
|
public static class WorkOrderBoardQueryFilters
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The window the Work Orders board searches when no date range is selected
|
||||||
|
/// (client ADVANCED_SEARCH_ALL_WEEKS_FROM/TO), used with includeDateless.
|
||||||
|
/// Counts that drill into that list use it too, so both see the same rows.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly DateOnly AllWeeksFrom = new(2000, 1, 1);
|
||||||
|
public static readonly DateOnly AllWeeksTo = new(2099, 12, 31);
|
||||||
|
|
||||||
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
|
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
|
||||||
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
|
|
@ -155,6 +163,44 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
|
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lower-cased legacy status text and the lifecycle status it stands for:
|
||||||
|
/// the Phase0 backfill rules, kept equal to LifecycleStatusMapper.FromLegacyStatus.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly IReadOnlyDictionary<string, LifecycleStatus> LegacyStatusValues =
|
||||||
|
new Dictionary<string, LifecycleStatus>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["open"] = LifecycleStatus.Incomplete,
|
||||||
|
["incomplete"] = LifecycleStatus.Incomplete,
|
||||||
|
["pending"] = LifecycleStatus.Pending,
|
||||||
|
["onhold"] = LifecycleStatus.Pending,
|
||||||
|
["on hold"] = LifecycleStatus.Pending,
|
||||||
|
["scheduled"] = LifecycleStatus.Scheduled,
|
||||||
|
["en route"] = LifecycleStatus.EnRoute,
|
||||||
|
["enroute"] = LifecycleStatus.EnRoute,
|
||||||
|
["on site"] = LifecycleStatus.OnSite,
|
||||||
|
["onsite"] = LifecycleStatus.OnSite,
|
||||||
|
["inprogress"] = LifecycleStatus.InProgress,
|
||||||
|
["in progress"] = LifecycleStatus.InProgress,
|
||||||
|
["completed"] = LifecycleStatus.Completed,
|
||||||
|
["complete"] = LifecycleStatus.Completed,
|
||||||
|
["done"] = LifecycleStatus.Completed,
|
||||||
|
["closed"] = LifecycleStatus.Completed,
|
||||||
|
["rescheduled"] = LifecycleStatus.Rescheduled,
|
||||||
|
["cancelled"] = LifecycleStatus.Canceled,
|
||||||
|
["canceled"] = LifecycleStatus.Canceled,
|
||||||
|
["pending quote"] = LifecycleStatus.PendingQuote,
|
||||||
|
["pendingquote"] = LifecycleStatus.PendingQuote,
|
||||||
|
["pendingapproval"] = LifecycleStatus.PendingQuote,
|
||||||
|
["pending approval"] = LifecycleStatus.PendingQuote,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps rows in <paramref name="statuses"/>. Rows with no LifecycleStatus
|
||||||
|
/// (legacy create paths still write only Status) match by their legacy
|
||||||
|
/// status, read as the Phase0 backfill does: known text maps through
|
||||||
|
/// <see cref="LegacyStatusValues"/>, anything else, blank included, is Incomplete.
|
||||||
|
/// </summary>
|
||||||
public static IQueryable<WorkOrder> ApplyStatusFilter(
|
public static IQueryable<WorkOrder> ApplyStatusFilter(
|
||||||
IQueryable<WorkOrder> query,
|
IQueryable<WorkOrder> query,
|
||||||
IReadOnlyList<LifecycleStatus>? statuses)
|
IReadOnlyList<LifecycleStatus>? statuses)
|
||||||
|
|
@ -162,7 +208,22 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
if (statuses == null || statuses.Count == 0)
|
if (statuses == null || statuses.Count == 0)
|
||||||
return query;
|
return query;
|
||||||
|
|
||||||
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
|
var legacyValues = LegacyStatusValues
|
||||||
|
.Where(entry => statuses.Contains(entry.Value))
|
||||||
|
.Select(entry => entry.Key)
|
||||||
|
.ToList();
|
||||||
|
var knownLegacyValues = LegacyStatusValues.Keys.ToList();
|
||||||
|
var includeUnknownLegacy = statuses.Contains(LifecycleStatus.Incomplete);
|
||||||
|
|
||||||
|
// Null check before Trim(): EF translates it, the in-memory provider runs it.
|
||||||
|
return query.Where(w =>
|
||||||
|
(w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value))
|
||||||
|
|| (w.LifecycleStatus == null
|
||||||
|
&& (((w.LegacyStatus ?? w.Status) != null
|
||||||
|
&& legacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower()))
|
||||||
|
|| (includeUnknownLegacy
|
||||||
|
&& ((w.LegacyStatus ?? w.Status) == null
|
||||||
|
|| !knownLegacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower()))))));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
|
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,41 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
|
||||||
|
.GetValues<LifecycleStatus>()
|
||||||
|
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public async Task<int> CountOpenUnassignedAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// A dispatcher-scoped Dashboard has no unassigned work by definition.
|
||||||
|
if (dispatcherId != null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
|
if (accountId is int scopedAccountId)
|
||||||
|
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
|
||||||
|
query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||||
|
query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null);
|
||||||
|
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
|
||||||
|
// All time counts what the board lists with no range: every week plus
|
||||||
|
// undated work, so the tile and its drill-down agree.
|
||||||
|
query = dateFrom is DateOnly from && dateTo is DateOnly to
|
||||||
|
? WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to)
|
||||||
|
: WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
|
||||||
|
query,
|
||||||
|
WorkOrderBoardQueryFilters.AllWeeksFrom,
|
||||||
|
WorkOrderBoardQueryFilters.AllWeeksTo,
|
||||||
|
includeDateless: true);
|
||||||
|
|
||||||
|
return await query.CountAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||||
int? accountId,
|
int? accountId,
|
||||||
string? dispatcherId,
|
string? dispatcherId,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,18 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
DateOnly today,
|
DateOnly today,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open work orders with no dispatcher in the period, counted with the
|
||||||
|
/// same filters as GET /board/search for Dispatcher=Unassigned,
|
||||||
|
/// so the Dashboard number matches the list it drills into.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountOpenUnassignedAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||||
int? accountId,
|
int? accountId,
|
||||||
string? dispatcherId,
|
string? dispatcherId,
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public int ScheduledTomorrow { get; set; }
|
public int ScheduledTomorrow { get; set; }
|
||||||
public int PendingUplifts { get; set; }
|
public int PendingUplifts { get; set; }
|
||||||
public int AvetaPending { get; set; }
|
public int AvetaPending { get; set; }
|
||||||
|
/// <summary>Open work orders with no dispatcher in the selected period.</summary>
|
||||||
|
public int Unassigned { 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; }
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
query.DateTo,
|
query.DateTo,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today());
|
var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today());
|
||||||
|
var unassigned = await _dataService.CountOpenUnassignedAsync(
|
||||||
|
accountId,
|
||||||
|
dispatcherId,
|
||||||
|
query.DateFrom,
|
||||||
|
query.DateTo,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null;
|
var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null;
|
||||||
|
|
||||||
|
|
@ -55,6 +61,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
ScheduledTomorrow = kpis.ScheduledTomorrow,
|
ScheduledTomorrow = kpis.ScheduledTomorrow,
|
||||||
PendingUplifts = kpis.PendingUplifts,
|
PendingUplifts = kpis.PendingUplifts,
|
||||||
AvetaPending = kpis.AvetaPending,
|
AvetaPending = kpis.AvetaPending,
|
||||||
|
Unassigned = unassigned,
|
||||||
Breakdown = metrics.Breakdown,
|
Breakdown = metrics.Breakdown,
|
||||||
DueCount = metrics.DueCount,
|
DueCount = metrics.DueCount,
|
||||||
CompletedDueCount = metrics.CompletedDueCount,
|
CompletedDueCount = metrics.CompletedDueCount,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.Services.Constants;
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
|
@ -30,6 +31,48 @@ public class LifecycleStatusMapperTests
|
||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every value FromLegacyStatus recognizes: the board's status filter reads
|
||||||
|
// rows with no LifecycleStatus through its own copy of this table.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("open")]
|
||||||
|
[InlineData("incomplete")]
|
||||||
|
[InlineData("pending")]
|
||||||
|
[InlineData("onhold")]
|
||||||
|
[InlineData("on hold")]
|
||||||
|
[InlineData("scheduled")]
|
||||||
|
[InlineData("en route")]
|
||||||
|
[InlineData("enroute")]
|
||||||
|
[InlineData("on site")]
|
||||||
|
[InlineData("onsite")]
|
||||||
|
[InlineData("inprogress")]
|
||||||
|
[InlineData("in progress")]
|
||||||
|
[InlineData("completed")]
|
||||||
|
[InlineData("complete")]
|
||||||
|
[InlineData("done")]
|
||||||
|
[InlineData("closed")]
|
||||||
|
[InlineData("rescheduled")]
|
||||||
|
[InlineData("cancelled")]
|
||||||
|
[InlineData("canceled")]
|
||||||
|
[InlineData("pending quote")]
|
||||||
|
[InlineData("pendingquote")]
|
||||||
|
[InlineData("pendingapproval")]
|
||||||
|
[InlineData("pending approval")]
|
||||||
|
public void BoardStatusFilterLegacyTable_AgreesWithFromLegacyStatus(string legacy)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
LifecycleStatusMapper.FromLegacyStatus(legacy),
|
||||||
|
WorkOrderBoardQueryFilters.LegacyStatusValues[legacy]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BoardStatusFilterLegacyTable_HasNoValueTheMapperRejects()
|
||||||
|
{
|
||||||
|
Assert.Equal(23, WorkOrderBoardQueryFilters.LegacyStatusValues.Count);
|
||||||
|
Assert.All(
|
||||||
|
WorkOrderBoardQueryFilters.LegacyStatusValues,
|
||||||
|
entry => Assert.Equal(entry.Value, LifecycleStatusMapper.FromLegacyStatus(entry.Key)));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(LifecycleStatus.EnRoute, "En Route")]
|
[InlineData(LifecycleStatus.EnRoute, "En Route")]
|
||||||
[InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
|
[InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue