fix(work-orders): count only live uplifts for Has uplift and the Uplift column

A work order whose uplifts were all cancelled, withdrawn, expired or revoked no
longer matches the advanced-search Has uplift filter, and the board Uplift
column no longer reports it as having an uplift or shows the dead one as its
primary status. One shared live-status list backs both queries. Explicit
cancelled/revoked sub-filter values still find those work orders.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 17:51:47 -03:00
parent cf32dd2698
commit 92cd44c481
5 changed files with 470 additions and 9 deletions

View file

@ -0,0 +1,26 @@
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// Which stored uplift statuses still count as a live uplift on a work order.
/// Cancelled (legacy "Cancelled", "Withdrawn", "Expired") and revoked uplifts do not;
/// pending, approved, auto-approved and rejected ones do. The advanced-search
/// "Has uplift" filter and the board Uplift column both use this, so they cannot drift.
/// </summary>
public static class LiveUpliftStatus
{
/// <summary>
/// Stored statuses that are not live. Kept as an array so EF translates
/// <c>NonLive.Contains(u.Status)</c> inside query predicates.
/// </summary>
public static readonly string[] NonLive =
{
"Withdrawn",
"Cancelled",
"Expired",
"Revoked",
};
public static bool IsLive(string? status) =>
status == null || !NonLive.Contains(status, StringComparer.Ordinal);
}
}

View file

@ -238,11 +238,13 @@ namespace SeaHaven.DataServices.Helpers
CancellationToken cancellationToken)
{
var workOrderIds = workOrders.Select(w => w.Id);
var nonLive = LiveUpliftStatus.NonLive;
var live = context.DispatchUpliftRequests
.AsNoTracking()
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& !nonLive.Contains(u.Status)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false));

View file

@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderType.Reactive,
};
private static readonly string[] RevokedUpliftStatuses =
{
"Withdrawn",
"Cancelled",
};
public static IQueryable<WorkOrder> ApplySeverityFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? severities)
@ -125,18 +119,21 @@ namespace SeaHaven.DataServices.Helpers
if (!hasUplift)
return query;
// No sub-filter: any live uplift. An explicit sub-filter matches exactly the
// statuses it names, so "cancelled" / "revoked" still find those work orders.
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
var anyLive = mappedStatuses.Count == 0;
var nonLive = LiveUpliftStatus.NonLive;
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Status != "Expired"
&& !RevokedUpliftStatuses.Contains(u.Status)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
&& (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
&& ((anyLive && !nonLive.Contains(u.Status))
|| (!anyLive && mappedStatuses.Contains(u.Status)))));
}
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
@ -166,6 +163,14 @@ namespace SeaHaven.DataServices.Helpers
mapped.Add("Rejected");
mapped.Add("Denied");
break;
case "cancelled":
mapped.Add("Withdrawn");
mapped.Add("Cancelled");
mapped.Add("Expired");
break;
case "revoked":
mapped.Add("Revoked");
break;
}
}

View file

@ -0,0 +1,162 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
namespace SeaHavenIndustries.Tests;
// Relational (SQLite) coverage for the live-uplift predicate. The in-memory provider
// evaluates LINQ in memory and cannot prove the "Has uplift" filter or the board uplift
// aggregate translate to SQL, so this runs the advanced search against a real provider,
// scoped to one account, with a live uplift on another account's work order.
public sealed class WorkOrderUpliftLiveStatusRelationalTests
{
[Fact]
public async Task AdvancedSearch_HasUplift_CountsOnlyLiveUpliftsWithinAccount()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new SqliteLiveUpliftTestDbContext(options);
await context.Database.EnsureCreatedAsync();
var accountA = new Accounts { Name = "Account A" };
var accountB = new Accounts { Name = "Account B" };
var vendor = new Vendor { CompanyName = "Acme HVAC", IsActive = true };
context.AddRange(accountA, accountB, vendor);
await context.SaveChangesAsync();
var revokedOnly = NewWorkOrder("WO-REVOKED", accountA.Id);
var cancelledOnly = NewWorkOrder("WO-CANCELLED", accountA.Id);
var revokedPlusPending = NewWorkOrder("WO-MIXED", accountA.Id);
var otherAccountPending = NewWorkOrder("WO-OTHER", accountB.Id);
context.AddRange(revokedOnly, cancelledOnly, revokedPlusPending, otherAccountPending);
await context.SaveChangesAsync();
var revokedDispatch = new Dispatch { VendorId = vendor.Id, DispatchNumber = "DIS-R", Status = "Scheduled" };
var cancelledDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = cancelledOnly.Id, DispatchNumber = "DIS-C", Status = "Scheduled" };
var mixedDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M", Status = "Scheduled" };
var mixedPendingDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M2", Status = "Scheduled" };
var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherAccountPending.Id, DispatchNumber = "DIS-O", Status = "Scheduled" };
context.Dispatches.AddRange(revokedDispatch, cancelledDispatch, mixedDispatch, mixedPendingDispatch, otherDispatch);
await context.SaveChangesAsync();
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = revokedDispatch.Id, WorkOrderId = revokedOnly.Id });
context.DispatchUpliftRequests.AddRange(
NewUplift(revokedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
NewUplift(cancelledDispatch.Id, "Withdrawn", new DateTime(2026, 6, 20, 9, 0, 0)),
NewUplift(mixedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
NewUplift(mixedPendingDispatch.Id, "Pending", new DateTime(2026, 6, 20, 11, 0, 0)),
NewUplift(otherDispatch.Id, "Pending", new DateTime(2026, 6, 20, 9, 0, 0)));
await context.SaveChangesAsync();
var data = new WorkOrderAdvancedSearchDataService(context);
var hasUplift = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: null));
var row = Assert.Single(hasUplift.Rows);
Assert.Equal(revokedPlusPending.Id, row.Id);
Assert.True(row.HasUplift);
Assert.Equal("Pending", row.PrimaryUpliftStatus);
var revoked = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: new[] { "revoked" }));
Assert.Equal(
new[] { revokedOnly.Id, revokedPlusPending.Id }.OrderBy(id => id),
revoked.Rows.Select(r => r.Id).OrderBy(id => id));
var all = await data.SearchAsync(Query(accountA.Id, hasUplift: false, upliftStatuses: null));
Assert.Equal(3, all.TotalCount);
Assert.DoesNotContain(all.Rows, r => r.Id == otherAccountPending.Id);
Assert.False(all.Rows.Single(r => r.Id == revokedOnly.Id).HasUplift);
Assert.Null(all.Rows.Single(r => r.Id == revokedOnly.Id).PrimaryUpliftStatus);
Assert.False(all.Rows.Single(r => r.Id == cancelledOnly.Id).HasUplift);
Assert.True(all.Rows.Single(r => r.Id == revokedPlusPending.Id).HasUplift);
}
private static WorkOrder NewWorkOrder(string number, int accountId) => new()
{
InternalWONumber = number,
WorkerOrderTitle = "Repair",
AccountId = accountId,
ScheduledDate = new DateTime(2026, 6, 23, 8, 0, 0),
LifecycleStatus = LifecycleStatus.Scheduled,
};
private static DispatchUpliftRequest NewUplift(int dispatchId, string status, DateTime createdDate) => new()
{
DispatchId = dispatchId,
Status = status,
CreatedDate = createdDate,
RequiredTier = 1,
RequestedNTE = 750m,
NotificationStatus = "Pending",
};
private static WorkOrderAdvancedSearchQuery Query(
int accountId,
bool hasUplift,
IReadOnlyList<string>? upliftStatuses) => new(
Search: null,
DateFrom: new DateOnly(2026, 6, 22),
DateTo: new DateOnly(2026, 6, 28),
UnscheduledOnly: false,
Sites: null,
Regions: null,
Types: null,
Overdue: false,
Dispatchers: null,
Statuses: null,
PmTypes: null,
VendorIds: null,
DocStatuses: null,
Severities: null,
Rescheduled: false,
CarriedOver: false,
AddOn: false,
AvetaOnly: false,
FlagColors: null,
InternalOnly: false,
HasUplift: hasUplift,
UpliftStatuses: upliftStatuses,
MyWorkOrders: false,
CurrentUserId: null,
Page: 0,
PageSize: 50,
SortBy: "scheduledDate",
SortDir: "asc",
AccountId: accountId);
private sealed class SqliteLiveUpliftTestDbContext : ApplicationDbContext
{
public SqliteLiveUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -0,0 +1,266 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// "Has uplift" (advanced filter) and the board Uplift column count only live uplifts:
/// a work order whose uplifts were all cancelled, withdrawn, expired or revoked has none.
/// </summary>
public class WorkOrderUpliftLiveStatusTests
{
private static readonly DateOnly WeekStart = new(2026, 6, 22);
private const int WithdrawnOnly = 1;
private const int LegacyCancelledOnly = 2;
private const int RevokedOnlyViaLink = 3;
private const int RevokedPlusPending = 4;
private const int RejectedOnly = 5;
private const int ExpiredOnly = 6;
private const int NoUplift = 7;
private const int NewerRevokedOlderAutoApproved = 8;
private static readonly int[] LiveUpliftWorkOrders =
{
RevokedPlusPending,
RejectedOnly,
NewerRevokedOlderAutoApproved,
};
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task<ApplicationDbContext> SeedAsync()
{
var context = CreateContext();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
for (var id = WithdrawnOnly; id <= NewerRevokedOlderAutoApproved; id++)
{
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
var linkedOnly = id == RevokedOnlyViaLink;
context.Dispatches.Add(new Dispatch
{
Id = 10 + id,
VendorId = 1,
WorkOrderId = linkedOnly ? null : id,
});
if (linkedOnly)
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10 + id, WorkOrderId = id });
context.workOrders.Add(new WorkOrder
{
Id = id,
InternalWONumber = $"1000000000{id}",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = linkedOnly ? null : 10 + id,
});
}
var older = new DateTime(2026, 6, 20, 9, 0, 0, DateTimeKind.Utc);
var newer = older.AddHours(2);
context.DispatchUpliftRequests.AddRange(
Uplift(101, WithdrawnOnly, "Withdrawn", older),
Uplift(102, LegacyCancelledOnly, "Cancelled", older),
Uplift(103, RevokedOnlyViaLink, "Revoked", older),
Uplift(104, RevokedPlusPending, "Revoked", older),
Uplift(105, RevokedPlusPending, "Pending", newer),
Uplift(106, RejectedOnly, "Rejected", older),
Uplift(107, ExpiredOnly, "Expired", older),
Uplift(108, NewerRevokedOlderAutoApproved, "NoApprovalRequired", older),
Uplift(109, NewerRevokedOlderAutoApproved, "Revoked", newer));
await context.SaveChangesAsync();
return context;
}
private static DispatchUpliftRequest Uplift(int id, int workOrderId, string status, DateTime createdDate) => new()
{
Id = id,
DispatchId = 10 + workOrderId,
RequestedNTE = 1000m + id,
Status = status,
RequiredTier = 1,
NotificationStatus = "Pending",
CreatedDate = createdDate,
};
private static async Task<int[]> SearchIdsAsync(
ApplicationDbContext context,
bool hasUplift,
List<string>? upliftStatuses = null)
{
var service = new WorkOrderAdvancedSearchService(
new WorkOrderAdvancedSearchDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(
new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = WeekStart,
DateTo = WeekStart.AddDays(6),
HasUplift = hasUplift,
UpliftStatuses = upliftStatuses,
PageSize = 50,
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
return result.Items.Select(i => i.Id).OrderBy(id => id).ToArray();
}
[Fact]
public async Task HasUplift_ExcludesWorkOrdersWhoseOnlyUpliftsAreCancelledOrRevoked()
{
await using var context = await SeedAsync();
var ids = await SearchIdsAsync(context, hasUplift: true);
Assert.Equal(LiveUpliftWorkOrders, ids);
Assert.DoesNotContain(WithdrawnOnly, ids);
Assert.DoesNotContain(LegacyCancelledOnly, ids);
Assert.DoesNotContain(RevokedOnlyViaLink, ids);
Assert.DoesNotContain(ExpiredOnly, ids);
}
[Fact]
public async Task HasUplift_IncludesWorkOrderWithRevokedAndPendingUplift()
{
await using var context = await SeedAsync();
Assert.Contains(RevokedPlusPending, await SearchIdsAsync(context, hasUplift: true));
Assert.Equal(
new[] { RevokedPlusPending },
await SearchIdsAsync(context, hasUplift: true, new List<string> { "pending" }));
}
[Fact]
public async Task HasUplift_RejectedUpliftStillCounts()
{
await using var context = await SeedAsync();
Assert.Equal(
new[] { RejectedOnly },
await SearchIdsAsync(context, hasUplift: true, new List<string> { "rejected" }));
}
[Fact]
public async Task HasUplift_ExplicitCancelledStatus_FindsCancelledWithdrawnAndExpiredUplifts()
{
await using var context = await SeedAsync();
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "cancelled" });
Assert.Equal(new[] { WithdrawnOnly, LegacyCancelledOnly, ExpiredOnly }, ids);
}
[Fact]
public async Task HasUplift_ExplicitRevokedStatus_FindsRevokedUpliftsOnPrimaryAndLinkedDispatches()
{
await using var context = await SeedAsync();
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "revoked" });
Assert.Equal(new[] { RevokedOnlyViaLink, RevokedPlusPending, NewerRevokedOlderAutoApproved }, ids);
}
[Fact]
public async Task BoardUpliftColumn_AgreesWithHasUpliftFilter()
{
await using var context = await SeedAsync();
var filtered = await SearchIdsAsync(context, hasUplift: true);
var boardService = new WorkOrderBoardService(
new WorkOrderBoardDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var board = await boardService.GetBoardAsync(
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(8, board.Scheduled.Count);
foreach (var row in board.Scheduled)
{
Assert.Equal(filtered.Contains(row.Id), row.UpliftSummary!.HasUplift);
}
var searchService = new WorkOrderAdvancedSearchService(
new WorkOrderAdvancedSearchDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var unfiltered = await searchService.SearchAsync(
new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = WeekStart,
DateTo = WeekStart.AddDays(6),
PageSize = 50,
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(8, unfiltered.Items.Count());
foreach (var item in unfiltered.Items)
{
Assert.Equal(filtered.Contains(item.Id), item.UpliftSummary!.HasUplift);
}
}
[Fact]
public async Task BoardUpliftColumn_ShowsNewestLiveUpliftAndNoStatusForNonLiveOnly()
{
await using var context = await SeedAsync();
var boardService = new WorkOrderBoardService(
new WorkOrderBoardDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var board = await boardService.GetBoardAsync(
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
var byId = board.Scheduled.ToDictionary(r => r.Id, r => r.UpliftSummary!);
foreach (var id in new[] { WithdrawnOnly, LegacyCancelledOnly, RevokedOnlyViaLink, ExpiredOnly, NoUplift })
{
Assert.False(byId[id].HasUplift);
Assert.Null(byId[id].PrimaryStatus);
Assert.Null(byId[id].Amount);
}
Assert.Equal("pending", byId[RevokedPlusPending].PrimaryStatus);
Assert.Equal(1, byId[RevokedPlusPending].PendingCount);
Assert.Equal("rejected", byId[RejectedOnly].PrimaryStatus);
Assert.Equal("auto_approved", byId[NewerRevokedOlderAutoApproved].PrimaryStatus);
Assert.Equal(1108m, byId[NewerRevokedOlderAutoApproved].Amount);
}
[Theory]
[InlineData(UpliftStatus.Pending)]
[InlineData(UpliftStatus.Approved)]
[InlineData(UpliftStatus.Rejected)]
[InlineData(UpliftStatus.ChangesRequested)]
[InlineData(UpliftStatus.Withdrawn)]
[InlineData(UpliftStatus.Expired)]
[InlineData(UpliftStatus.NoApprovalRequired)]
[InlineData(UpliftStatus.Revoked)]
[InlineData(UpliftStatus.LegacyDenied)]
[InlineData(UpliftStatus.LegacyCancelled)]
public void LiveUpliftStatus_MatchesFrontendCancelledAndRevokedStatuses(string storedStatus)
{
var frontendStatus = WorkOrderUpliftContractMapper.ToFrontendStatus(storedStatus);
var expectedLive = frontendStatus is not ("cancelled" or "revoked");
Assert.Equal(expectedLive, LiveUpliftStatus.IsLive(storedStatus));
}
}