mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 17:02:12 +00:00
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.
This commit is contained in:
parent
039a9c8d7e
commit
468522c3f7
3 changed files with 168 additions and 10 deletions
|
|
@ -281,6 +281,48 @@ public sealed class UpliftQueueReadTests
|
||||||
item.Status.Should().Be("Pending");
|
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]
|
[Fact]
|
||||||
public async Task List_RequesterLabelFallsBackToCreatingUserName()
|
public async Task List_RequesterLabelFallsBackToCreatingUserName()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,24 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
from v in vendors.DefaultIfEmpty()
|
from v in vendors.DefaultIfEmpty()
|
||||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
|
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
|
||||||
from ev in evidences.DefaultIfEmpty()
|
from ev in evidences.DefaultIfEmpty()
|
||||||
join wo in _context.workOrders on d.WorkOrderId equals wo.Id into workOrders
|
// SH-207: resolve the work order through the same primary-plus-linked
|
||||||
from wo in workOrders.DefaultIfEmpty()
|
// 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
|
join reqUser in _context.Users on u.createdby equals reqUser.Id into requestUsers
|
||||||
from reqUser in requestUsers.DefaultIfEmpty()
|
from reqUser in requestUsers.DefaultIfEmpty()
|
||||||
join decUser in _context.Users on u.DecidedByUserId equals decUser.Id into decUsers
|
join decUser in _context.Users on u.DecidedByUserId equals decUser.Id into decUsers
|
||||||
from decUser in decUsers.DefaultIfEmpty()
|
from decUser in decUsers.DefaultIfEmpty()
|
||||||
where (u.IsDeleted == null || u.IsDeleted == false)
|
where (u.IsDeleted == null || u.IsDeleted == false)
|
||||||
&& (d.IsDeleted == null || d.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))
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
query = query.Where(x => x.u.Status == status);
|
query = query.Where(x => x.u.Status == status);
|
||||||
|
|
@ -62,16 +71,16 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DispatchId = x.d.Id,
|
DispatchId = x.d.Id,
|
||||||
DispatchNumber = x.d.DispatchNumber,
|
DispatchNumber = x.d.DispatchNumber,
|
||||||
PONumber = x.d.PONumber,
|
PONumber = x.d.PONumber,
|
||||||
WorkOrderId = x.d.WorkOrderId,
|
WorkOrderId = x.effectiveWorkOrderId,
|
||||||
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
|
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
|
||||||
WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null,
|
WorkOrderNumber = x.workOrder != null ? x.workOrder.WorkerOrderNumber : null,
|
||||||
WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null,
|
WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null,
|
||||||
WorkOrderService = x.wo != null ? x.wo.Service : null,
|
WorkOrderService = x.workOrder != null ? x.workOrder.Service : 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.wo != null
|
WorkOrderClosed = x.workOrder != null
|
||||||
&& (x.wo.LifecycleStatus == LifecycleStatus.Completed
|
&& (x.workOrder.LifecycleStatus == LifecycleStatus.Completed
|
||||||
|| x.wo.LifecycleStatus == LifecycleStatus.Canceled),
|
|| x.workOrder.LifecycleStatus == LifecycleStatus.Canceled),
|
||||||
// SH-207: every non-deleted UpliftEvidence document on the dispatch is
|
// SH-207: every non-deleted UpliftEvidence document on the dispatch is
|
||||||
// an attachment reviewers can open; the linked evidence is one of them.
|
// an attachment reviewers can open; the linked evidence is one of them.
|
||||||
AttachmentCount = x.d.CompletionDocuments.Count(doc =>
|
AttachmentCount = x.d.CompletionDocuments.Count(doc =>
|
||||||
|
|
|
||||||
107
SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs
Normal file
107
SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs
Normal file
|
|
@ -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<ApplicationDbContext>()
|
||||||
|
.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<ApplicationDbContext> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue