From 468522c3f7912a69060f5f588d24f6904db2b614 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 17 Sep 2026 03:47:09 -0300 Subject: [PATCH] fix(uplift): resolve linked-only work order in queue list read GetPagedAsync resolved WorkOrderNumber/Site/Service, WorkOrderClosed, and WorkOrderId only through Dispatch.WorkOrderId, so a dispatch linked to its work order solely through DispatchWorkOrders surfaced blank WO context, workOrderClosed:false, and zero exposure. Resolve the effective work order using the same primary-plus-linked convention as GetWorkOrderIdForUpliftAsync and GetApprovedExposureForWorkOrdersAsync via a single work-order lookup. --- .../UpliftQueueReadTests.cs | 42 +++++++ .../Implementation/UpliftDataService.cs | 29 +++-- .../UpliftQueueReadRelationalTests.cs | 107 ++++++++++++++++++ 3 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index a45f0e1..0973536 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -281,6 +281,48 @@ public sealed class UpliftQueueReadTests item.Status.Should().Be("Pending"); } + [Fact] + public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders() + { + // A dispatch can carry no primary WorkOrderId yet be linked to a work order + // through DispatchWorkOrders (the supported shape exercised by + // Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks). The queue + // read must resolve the same effective work order the sibling reads use, or the + // Approved tab surfaces blank WO context, workOrderClosed:false, and zero + // exposure on a terminal work order. + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-LINKED", "SITE-Z", "Electrical"); + workOrder.LifecycleStatus = LifecycleStatus.Completed; + var linkedDispatch = new Dispatch + { + VendorId = vendor.Id, + DispatchNumber = "DIS-LINKED", + Status = "Completed" + }; + context.Dispatches.Add(linkedDispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = linkedDispatch.Id, + WorkOrderId = workOrder.Id + }); + context.DispatchUpliftRequests.Add(Request( + linkedDispatch, "Approved", new DateTime(2026, 3, 1), + decided: new DateTime(2026, 3, 2), requested: 90m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderId.Should().Be(workOrder.Id); + item.WorkOrderNumber.Should().Be("WO-LINKED"); + item.WorkOrderSite.Should().Be("SITE-Z"); + item.WorkOrderService.Should().Be("Electrical"); + item.WorkOrderClosed.Should().BeTrue(); + item.WorkOrderApprovedExposureTotal.Should().Be(90m); + } + [Fact] public async Task List_RequesterLabelFallsBackToCreatingUserName() { diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 3535e0e..b6f9940 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -25,15 +25,24 @@ namespace SeaHaven.DataServices.Implementation from v in vendors.DefaultIfEmpty() join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences from ev in evidences.DefaultIfEmpty() - join wo in _context.workOrders on d.WorkOrderId equals wo.Id into workOrders - from wo in workOrders.DefaultIfEmpty() + // SH-207: resolve the work order through the same primary-plus-linked + // convention the sibling reads use (GetWorkOrderIdForUpliftAsync, + // GetApprovedExposureForWorkOrdersAsync). A dispatch whose work order is + // linked only through DispatchWorkOrders still surfaces its work-order id, + // flattened WO context, the closed flag, and approved exposure. The + // effective id feeds a single work-order lookup so every field resolves + // from one row. + let effectiveWorkOrderId = d.WorkOrderId + ?? d.DispatchWorkOrders!.Select(link => (int?)link.WorkOrderId).FirstOrDefault() + let workOrder = _context.workOrders + .FirstOrDefault(candidate => candidate.Id == effectiveWorkOrderId) join reqUser in _context.Users on u.createdby equals reqUser.Id into requestUsers from reqUser in requestUsers.DefaultIfEmpty() join decUser in _context.Users on u.DecidedByUserId equals decUser.Id into decUsers from decUser in decUsers.DefaultIfEmpty() where (u.IsDeleted == null || u.IsDeleted == false) && (d.IsDeleted == null || d.IsDeleted == false) - select new { u, d, v, ev, wo, reqUser, decUser }; + select new { u, d, v, ev, effectiveWorkOrderId, workOrder, reqUser, decUser }; if (!string.IsNullOrWhiteSpace(status)) query = query.Where(x => x.u.Status == status); @@ -62,16 +71,16 @@ namespace SeaHaven.DataServices.Implementation DispatchId = x.d.Id, DispatchNumber = x.d.DispatchNumber, PONumber = x.d.PONumber, - WorkOrderId = x.d.WorkOrderId, + WorkOrderId = x.effectiveWorkOrderId, VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, - WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null, - WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null, - WorkOrderService = x.wo != null ? x.wo.Service : null, + WorkOrderNumber = x.workOrder != null ? x.workOrder.WorkerOrderNumber : null, + WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null, + WorkOrderService = x.workOrder != null ? x.workOrder.Service : null, // SH-208: a work order is closed for uplift decisions once its // lifecycle reaches a terminal state; mirrors the SH-196 revoke guard. - WorkOrderClosed = x.wo != null - && (x.wo.LifecycleStatus == LifecycleStatus.Completed - || x.wo.LifecycleStatus == LifecycleStatus.Canceled), + WorkOrderClosed = x.workOrder != null + && (x.workOrder.LifecycleStatus == LifecycleStatus.Completed + || x.workOrder.LifecycleStatus == LifecycleStatus.Canceled), // SH-207: every non-deleted UpliftEvidence document on the dispatch is // an attachment reviewers can open; the linked evidence is one of them. AttachmentCount = x.d.CompletionDocuments.Count(doc => diff --git a/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs new file mode 100644 index 0000000..b4b6be5 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs @@ -0,0 +1,107 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +// Relational (SQLite) coverage for the uplift queue read. The in-memory provider +// evaluates LINQ in memory and cannot prove the query translates to SQL, so this +// executes GetPagedAsync against a real relational provider and asserts a dispatch +// linked only through DispatchWorkOrders still resolves its work-order context and +// closed flag through the effective primary-plus-linked work order. +public sealed class UpliftQueueReadRelationalTests +{ + [Fact] + public async Task GetPagedAsync_ResolvesWorkOrder_ForDispatchLinkedOnlyThroughDispatchWorkOrders() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteUpliftTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder + { + WorkerOrderNumber = "WO-LINKED", + SiteCode = "SITE-Z", + Service = "Electrical", + WorkerOrderTitle = "Repair", + LifecycleStatus = LifecycleStatus.Completed + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + + var dispatch = new Dispatch + { + VendorId = vendor.Id, + DispatchNumber = "DIS-LINKED", + Status = "Completed" + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Approved", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 90m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + + var (total, items) = await data.GetPagedAsync("Approved", null, 1, 25, CancellationToken.None); + + Assert.Equal(1, total); + var item = Assert.Single(items); + Assert.Equal(workOrder.Id, item.WorkOrderId); + Assert.Equal("WO-LINKED", item.WorkOrderNumber); + Assert.Equal("SITE-Z", item.WorkOrderSiteCode); + Assert.Equal("Electrical", item.WorkOrderService); + Assert.True(item.WorkOrderClosed); + } + + private sealed class SqliteUpliftTestDbContext : ApplicationDbContext + { + public SqliteUpliftTestDbContext(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; + } + } + } +}