mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 20:02:09 +00:00
Merge pull request #151 from Sea-Haven-Industries/feat/ab/sh-209-uplift-detail-context
SH-209: Expose dispatcher, technician and schedule on the uplift queue read
This commit is contained in:
commit
a9773e67eb
6 changed files with 196 additions and 0 deletions
|
|
@ -282,6 +282,92 @@ public sealed class UpliftQueueReadTests
|
||||||
item.Status.Should().Be("Pending");
|
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]
|
[Fact]
|
||||||
public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
|
public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,12 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? WorkOrderNumber { get; set; }
|
public string? WorkOrderNumber { get; set; }
|
||||||
public string? WorkOrderSiteCode { get; set; }
|
public string? WorkOrderSiteCode { get; set; }
|
||||||
public string? WorkOrderService { 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 bool WorkOrderClosed { get; set; }
|
||||||
public int AttachmentCount { get; set; }
|
public int AttachmentCount { get; set; }
|
||||||
public string? RequestedByVendorName { get; set; }
|
public string? RequestedByVendorName { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,18 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
WorkOrderNumber = x.workOrder != null ? x.workOrder.InternalWONumber : null,
|
WorkOrderNumber = x.workOrder != null ? x.workOrder.InternalWONumber : null,
|
||||||
WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null,
|
WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null,
|
||||||
WorkOrderService = x.workOrder != null ? x.workOrder.Service : 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
|
// SH-208: a work order is closed for uplift decisions once its
|
||||||
// lifecycle reaches a terminal state; mirrors the SH-196 revoke guard.
|
// lifecycle reaches a terminal state; mirrors the SH-196 revoke guard.
|
||||||
WorkOrderClosed = x.workOrder != null
|
WorkOrderClosed = x.workOrder != null
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? WorkOrderNumber { get; set; }
|
public string? WorkOrderNumber { get; set; }
|
||||||
public string? WorkOrderSite { get; set; }
|
public string? WorkOrderSite { get; set; }
|
||||||
public string? WorkOrderService { 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? RequestedByName { get; set; }
|
||||||
public string? DecidedByName { get; set; }
|
public string? DecidedByName { get; set; }
|
||||||
public decimal? CurrentNTE { get; set; }
|
public decimal? CurrentNTE { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrderNumber = r.WorkOrderNumber,
|
WorkOrderNumber = r.WorkOrderNumber,
|
||||||
WorkOrderSite = r.WorkOrderSiteCode,
|
WorkOrderSite = r.WorkOrderSiteCode,
|
||||||
WorkOrderService = r.WorkOrderService,
|
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),
|
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
|
||||||
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
||||||
CurrentNTE = r.CurrentNTE,
|
CurrentNTE = r.CurrentNTE,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,91 @@ public sealed class UpliftQueueReadRelationalTests
|
||||||
Assert.True(item.WorkOrderClosed);
|
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
|
private sealed class SqliteUpliftTestDbContext : ApplicationDbContext
|
||||||
{
|
{
|
||||||
public SqliteUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
public SqliteUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue