From 92cd44c4819eda76f00405e09704068ad13953ea Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 17:51:47 -0300 Subject: [PATCH] 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. --- .../Helpers/LiveUpliftStatus.cs | 26 ++ .../Helpers/WorkOrderBoardProjection.cs | 2 + .../WorkOrderBoardSearchFacetFilters.cs | 23 +- ...orkOrderUpliftLiveStatusRelationalTests.cs | 162 +++++++++++ .../WorkOrderUpliftLiveStatusTests.cs | 266 ++++++++++++++++++ 5 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs diff --git a/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs b/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs new file mode 100644 index 0000000..e976eea --- /dev/null +++ b/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs @@ -0,0 +1,26 @@ +namespace SeaHaven.DataServices.Helpers +{ + /// + /// 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. + /// + public static class LiveUpliftStatus + { + /// + /// Stored statuses that are not live. Kept as an array so EF translates + /// NonLive.Contains(u.Status) inside query predicates. + /// + public static readonly string[] NonLive = + { + "Withdrawn", + "Cancelled", + "Expired", + "Revoked", + }; + + public static bool IsLive(string? status) => + status == null || !NonLive.Contains(status, StringComparer.Ordinal); + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 12d22ce..e14972f 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -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)); diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs index d3bdfea..61afea5 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs @@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers WorkOrderType.Reactive, }; - private static readonly string[] RevokedUpliftStatuses = - { - "Withdrawn", - "Cancelled", - }; - public static IQueryable ApplySeverityFilter( IQueryable query, IReadOnlyList? 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 MapUpliftStatuses(IReadOnlyList? 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; } } diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs new file mode 100644 index 0000000..d1f3032 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs @@ -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() + .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? 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 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; + } + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs new file mode 100644 index 0000000..0370751 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs @@ -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; + +/// +/// "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. +/// +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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task 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 SearchIdsAsync( + ApplicationDbContext context, + bool hasUplift, + List? 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 { "pending" })); + } + + [Fact] + public async Task HasUplift_RejectedUpliftStillCounts() + { + await using var context = await SeedAsync(); + + Assert.Equal( + new[] { RejectedOnly }, + await SearchIdsAsync(context, hasUplift: true, new List { "rejected" })); + } + + [Fact] + public async Task HasUplift_ExplicitCancelledStatus_FindsCancelledWithdrawnAndExpiredUplifts() + { + await using var context = await SeedAsync(); + + var ids = await SearchIdsAsync(context, hasUplift: true, new List { "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 { "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)); + } +}