mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
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.
This commit is contained in:
parent
63f80990cf
commit
fa979605b4
6 changed files with 196 additions and 0 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ApplicationDbContext>()
|
||||
.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<ApplicationDbContext> options)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue