From fa979605b42aa5f2e6b1510b05fff8951844559c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 18 Sep 2026 12:35:35 -0300 Subject: [PATCH] feat(uplifts): expose dispatcher, technician and schedule on queue read (SH-209) The uplift detail modal needs the work order's assigned dispatcher, the requesting vendor's technician and the scheduled date. They now resolve from the same effective work order and vendor as the existing queue row, so the modal no longer depends on a separate work-order fetch that account-scoped staff cannot read. --- .../UpliftQueueReadTests.cs | 86 +++++++++++++++++++ .../Models/VendorPortalReadModels.cs | 6 ++ .../Implementation/UpliftDataService.cs | 12 +++ SeaHaven.Services/DTOs/UpliftDTOs.cs | 4 + .../Implementation/UpliftService.cs | 3 + .../UpliftQueueReadRelationalTests.cs | 85 ++++++++++++++++++ 6 files changed, 196 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 84b86bc..78fe0ac 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -282,6 +282,92 @@ public sealed class UpliftQueueReadTests item.Status.Should().Be("Pending"); } + [Fact] + public async Task List_MapsDetailModalWorkOrderContext_DispatcherTechnicianAndScheduledDate() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-81", "SITE-N", "Plumbing"); + vendor.ContactName = " Tom Tech "; + workOrder.ScheduledDate = new DateTime(2026, 4, 10, 9, 30, 0); + workOrder.AssignTo = "dispatcher-1"; + context.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Dana", LastName = "Ruiz" }); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-81"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 4, 10, 9, 30, 0)); + item.WorkOrderDispatcherName.Should().Be("Dana Ruiz"); + item.TechnicianName.Should().Be("Tom Tech"); + item.VendorCompanyName.Should().Be("Gateway"); + } + + [Fact] + public async Task List_DetailModalWorkOrderContext_IsNullForUnassignedUnscheduledWorkOrder() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-82", "SITE-N", "Plumbing"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-82"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderScheduledDate.Should().BeNull(); + item.WorkOrderDispatcherName.Should().BeNull(); + item.TechnicianName.Should().BeNull(); + } + + [Fact] + public async Task List_DetailModalWorkOrderContext_NeverCrossesAccounts() + { + // Two tenants' work orders sit side by side. Each queue row must carry the + // dispatcher, technician and schedule of its own work order and vendor only; + // another account's work order never supplies them. + using var context = NewContext(); + var accountA = new Accounts { Name = "Account A" }; + var accountB = new Accounts { Name = "Account B" }; + context.AddRange(accountA, accountB); + context.Users.AddRange( + new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" }, + new ApplicationUser { Id = "dispatcher-b", FirstName = "Ben", LastName = "Beta" }); + await context.SaveChangesAsync(); + + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Vendor A"); + vendorA.ContactName = "Tech A"; + workOrderA.AccountId = accountA.Id; + workOrderA.AssignTo = "dispatcher-a"; + workOrderA.ScheduledDate = new DateTime(2026, 5, 1); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Roofing", "Vendor B"); + vendorB.ContactName = "Tech B"; + workOrderB.AccountId = accountB.Id; + workOrderB.AssignTo = "dispatcher-b"; + workOrderB.ScheduledDate = new DateTime(2026, 6, 1); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Pending", new DateTime(2026, 4, 1)), + Request(dispatchB, "Pending", new DateTime(2026, 4, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var rowA = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderA.Id).Subject; + rowA.WorkOrderDispatcherName.Should().Be("Ann Alpha"); + rowA.TechnicianName.Should().Be("Tech A"); + rowA.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 5, 1)); + var rowB = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderB.Id).Subject; + rowB.WorkOrderDispatcherName.Should().Be("Ben Beta"); + rowB.TechnicianName.Should().Be("Tech B"); + rowB.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 6, 1)); + } + [Fact] public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders() { diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 885419d..031cf01 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -85,6 +85,12 @@ namespace Data.SeaHavenIndustries public string? WorkOrderNumber { get; set; } public string? WorkOrderSiteCode { get; set; } public string? WorkOrderService { get; set; } + // SH-209: detail modal context, resolved from the same effective work order + // and the requesting dispatch's vendor. + public DateTime? WorkOrderScheduledDate { get; set; } + public string? WorkOrderDispatcherFirstName { get; set; } + public string? WorkOrderDispatcherLastName { get; set; } + public string? TechnicianName { get; set; } public bool WorkOrderClosed { get; set; } public int AttachmentCount { get; set; } public string? RequestedByVendorName { get; set; } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 788c754..7db6a01 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -79,6 +79,18 @@ namespace SeaHaven.DataServices.Implementation WorkOrderNumber = x.workOrder != null ? x.workOrder.InternalWONumber : null, WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null, WorkOrderService = x.workOrder != null ? x.workOrder.Service : null, + // SH-209: the detail modal reads its work-order context from this row, + // so it never depends on a separate work-order fetch. Technician + // follows the board convention (vendor contact) for the requesting + // dispatch's vendor, the same vendor as VendorCompanyName. + WorkOrderScheduledDate = x.workOrder != null ? x.workOrder.ScheduledDate : null, + WorkOrderDispatcherFirstName = x.workOrder != null && x.workOrder.AssignToUser != null + ? x.workOrder.AssignToUser.FirstName + : null, + WorkOrderDispatcherLastName = x.workOrder != null && x.workOrder.AssignToUser != null + ? x.workOrder.AssignToUser.LastName + : null, + TechnicianName = x.v != null ? x.v.ContactName : 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.workOrder != null diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs index b2b8544..24cde20 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -12,6 +12,10 @@ namespace SeaHaven.Services.DTOs public string? WorkOrderNumber { get; set; } public string? WorkOrderSite { get; set; } public string? WorkOrderService { get; set; } + // SH-209: detail modal work-order context. + public DateTime? WorkOrderScheduledDate { get; set; } + public string? WorkOrderDispatcherName { get; set; } + public string? TechnicianName { get; set; } public string? RequestedByName { get; set; } public string? DecidedByName { get; set; } public decimal? CurrentNTE { get; set; } diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 5144563..5459c36 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -49,6 +49,9 @@ namespace SeaHaven.Services.Implementation WorkOrderNumber = r.WorkOrderNumber, WorkOrderSite = r.WorkOrderSiteCode, WorkOrderService = r.WorkOrderService, + WorkOrderScheduledDate = r.WorkOrderScheduledDate, + WorkOrderDispatcherName = ResolveRequestedByName(null, r.WorkOrderDispatcherFirstName, r.WorkOrderDispatcherLastName), + TechnicianName = string.IsNullOrWhiteSpace(r.TechnicianName) ? null : r.TechnicianName.Trim(), RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName), DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName), CurrentNTE = r.CurrentNTE, diff --git a/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs index 6fdc8e2..d9f2cce 100644 --- a/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs +++ b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs @@ -80,6 +80,91 @@ public sealed class UpliftQueueReadRelationalTests Assert.True(item.WorkOrderClosed); } + [Fact] + public async Task GetPagedAsync_ResolvesDetailModalContext_PerAccountWorkOrder() + { + // SH-209: dispatcher (work-order assignee navigation), technician (vendor + // contact) and scheduled date must translate to SQL through the effective + // work-order subquery, and each row resolves them from its own account's + // work order only. + 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 accountA = new Accounts { Name = "Account A" }; + var accountB = new Accounts { Name = "Account B" }; + var dispatcherA = new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" }; + var vendorA = new Vendor { CompanyName = "Vendor A", ContactName = "Tech A", IsActive = true }; + var vendorB = new Vendor { CompanyName = "Vendor B", ContactName = "Tech B", IsActive = true }; + context.AddRange(accountA, accountB, dispatcherA, vendorA, vendorB); + await context.SaveChangesAsync(); + + var workOrderA = new WorkOrder + { + InternalWONumber = "WO-A", + WorkerOrderTitle = "Repair", + AccountId = accountA.Id, + AssignTo = dispatcherA.Id, + ScheduledDate = new DateTime(2026, 5, 1, 8, 0, 0) + }; + var workOrderB = new WorkOrder + { + InternalWONumber = "WO-B", + WorkerOrderTitle = "Repair", + AccountId = accountB.Id + }; + context.AddRange(workOrderA, workOrderB); + await context.SaveChangesAsync(); + + var dispatchA = new Dispatch { VendorId = vendorA.Id, WorkOrderId = workOrderA.Id, DispatchNumber = "DIS-A", Status = "Scheduled" }; + var dispatchB = new Dispatch { VendorId = vendorB.Id, WorkOrderId = workOrderB.Id, DispatchNumber = "DIS-B", Status = "Scheduled" }; + context.Dispatches.AddRange(dispatchA, dispatchB); + await context.SaveChangesAsync(); + + context.DispatchUpliftRequests.AddRange( + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 4, 1), + RequiredTier = 1, + RequestedNTE = 100m, + NotificationStatus = "Pending" + }, + new DispatchUpliftRequest + { + DispatchId = dispatchB.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 4, 2), + RequiredTier = 1, + RequestedNTE = 200m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + + var (total, items) = await data.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None); + + Assert.Equal(2, total); + var rowA = Assert.Single(items, i => i.WorkOrderId == workOrderA.Id); + Assert.Equal("Ann", rowA.WorkOrderDispatcherFirstName); + Assert.Equal("Alpha", rowA.WorkOrderDispatcherLastName); + Assert.Equal("Tech A", rowA.TechnicianName); + Assert.Equal(new DateTime(2026, 5, 1, 8, 0, 0), rowA.WorkOrderScheduledDate); + var rowB = Assert.Single(items, i => i.WorkOrderId == workOrderB.Id); + Assert.Null(rowB.WorkOrderDispatcherFirstName); + Assert.Null(rowB.WorkOrderDispatcherLastName); + Assert.Equal("Tech B", rowB.TechnicianName); + Assert.Null(rowB.WorkOrderScheduledDate); + } + private sealed class SqliteUpliftTestDbContext : ApplicationDbContext { public SqliteUpliftTestDbContext(DbContextOptions options)