Compare commits

...

6 commits

Author SHA1 Message Date
Alexandre Brandizzi
06eae2fb02
Merge pull request #175 from Sea-Haven-Industries/feat/ab/sh-392-dashboard-unassigned
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
SH-392: count open unassigned work orders on the Dashboard
2026-09-25 06:46:01 +00:00
Alexandre Brandizzi
702069f09c test(dashboard): pin drill-down rows for legacy open work orders
The parity test now also asserts which rows the drill-down lists: legacy
open rows (status in Status or in LegacyStatus, or none) are listed and
legacy closed, cancelled or assigned rows are not. Drops ticket keys
from comments.
2026-09-25 03:38:31 -03:00
Alexandre Brandizzi
d6c5357fab fix(dashboard): count unassigned legacy rows with no lifecycle status (SH-392)
The legacy create paths (WorkOrderDTOs, SyncService, the Blazor
WorkorderService) still write Status without LifecycleStatus. The board
status filter only matched LifecycleStatus. So an open unassigned row of
that kind was left out of the Unassigned tile and its drill-down list,
even though the Open tile in the same response counted it.

ApplyStatusFilter now reads a row with no LifecycleStatus by its legacy
status (LegacyStatus ?? Status), using the Phase0 backfill rules: known
text maps as LifecycleStatusMapper does, and anything else, blank
included, counts as Incomplete. The tile and /board/search share the
predicate, so the count still matches the list it opens.
2026-09-25 03:34:24 -03:00
Alexandre Brandizzi
c49a98db18
Merge branch 'main' into feat/ab/sh-392-dashboard-unassigned 2026-09-25 03:27:33 -03:00
Alexandre Brandizzi
7297e30212 fix(dashboard): count All time over the same window the board lists (SH-392)
The Unassigned drill-down opens the board with no range, which searches
2000-01-01..2099-12-31 plus undated work. The All time count had no
bounds, so a work order dated outside that window was counted but not
listed. The count now uses the same window.
2026-09-24 21:45:06 -03:00
Alexandre Brandizzi
4876fa2717 feat(dashboard): count open unassigned work orders for the period (SH-392)
GET /dashboard/stats now returns unassigned: open (not Completed or
Canceled) work orders with no dispatcher in the selected period, within the
caller's server-derived account scope. It uses the same filters as the
Unassigned board search, so the Dashboard number equals the list it drills
into. A dispatcher-scoped Dashboard has no unassigned work and returns 0.
2026-09-24 21:11:31 -03:00
9 changed files with 414 additions and 2 deletions

View file

@ -40,7 +40,8 @@ public class DashboardControllerTests
{
ScheduledTomorrow = 3,
PendingUplifts = 5,
AvetaPending = 7
AvetaPending = 7,
Unassigned = 11
});
var result = await NewController(service).GetStats(
@ -50,5 +51,6 @@ public class DashboardControllerTests
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
((int)Prop(body, "pendingUplifts")).Should().Be(5);
((int)Prop(body, "avetaPending")).Should().Be(7);
((int)Prop(body, "unassigned")).Should().Be(11);
}
}

View 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 });
}
}

View file

@ -33,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
scheduledTomorrow = stats.ScheduledTomorrow,
pendingUplifts = stats.PendingUplifts,
avetaPending = stats.AvetaPending,
unassigned = stats.Unassigned,
breakdown = stats.Breakdown,
dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount,

View file

@ -5,6 +5,14 @@ namespace SeaHaven.DataServices.Helpers
{
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)
=> 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))));
}
/// <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(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)
@ -162,7 +208,22 @@ namespace SeaHaven.DataServices.Helpers
if (statuses == null || statuses.Count == 0)
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(

View file

@ -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(
int? accountId,
string? dispatcherId,

View file

@ -14,6 +14,18 @@ namespace SeaHaven.DataServices.Interfaces
DateOnly today,
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(
int? accountId,
string? dispatcherId,

View file

@ -106,6 +106,8 @@ namespace SeaHaven.Services.DTOs
public int ScheduledTomorrow { get; set; }
public int PendingUplifts { 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 int DueCount { get; set; }
public int CompletedDueCount { get; set; }

View file

@ -43,6 +43,12 @@ namespace SeaHaven.Services.Implementation
query.DateTo,
cancellationToken);
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;
@ -55,6 +61,7 @@ namespace SeaHaven.Services.Implementation
ScheduledTomorrow = kpis.ScheduledTomorrow,
PendingUplifts = kpis.PendingUplifts,
AvetaPending = kpis.AvetaPending,
Unassigned = unassigned,
Breakdown = metrics.Breakdown,
DueCount = metrics.DueCount,
CompletedDueCount = metrics.CompletedDueCount,

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Helpers;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
@ -30,6 +31,48 @@ public class LifecycleStatusMapperTests
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]
[InlineData(LifecycleStatus.EnRoute, "En Route")]
[InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]