mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
Merge pull request #144 from Sea-Haven-Industries/feat/ab/sh-207-uplift-queue-flags
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging through Terraform (push) Blocked by required conditions
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging through Terraform (push) Blocked by required conditions
SH-207: Complete uplift queue list contract (closed flag, attachments, decider, pending exposure)
This commit is contained in:
commit
63f80990cf
7 changed files with 348 additions and 78 deletions
|
|
@ -79,13 +79,23 @@ public class UpliftControllerTests
|
|||
WorkOrderNumber = "WO-77",
|
||||
WorkOrderSite = "SITE-EAST",
|
||||
WorkOrderService = "HVAC",
|
||||
WorkOrderClosed = true,
|
||||
AttachmentCount = 2,
|
||||
RequestedByName = "Gateway",
|
||||
DecidedByName = "Grace Hopper",
|
||||
WorkOrderAutoApprovedTotal = 150m,
|
||||
WorkOrderAdminApprovedTotal = 300m,
|
||||
WorkOrderApprovedExposureTotal = 450m
|
||||
};
|
||||
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new UpliftListResultDTO { Total = 1, Page = 1, PageSize = 25, Items = new[] { item } });
|
||||
.ReturnsAsync(new UpliftListResultDTO
|
||||
{
|
||||
Total = 1,
|
||||
Page = 1,
|
||||
PageSize = 25,
|
||||
PendingExposureTotal = 200m,
|
||||
Items = new[] { item }
|
||||
});
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -98,8 +108,12 @@ public class UpliftControllerTests
|
|||
returned.WorkOrderNumber.Should().Be("WO-77");
|
||||
returned.WorkOrderSite.Should().Be("SITE-EAST");
|
||||
returned.WorkOrderService.Should().Be("HVAC");
|
||||
returned.WorkOrderClosed.Should().BeTrue();
|
||||
returned.AttachmentCount.Should().Be(2);
|
||||
returned.RequestedByName.Should().Be("Gateway");
|
||||
returned.DecidedByName.Should().Be("Grace Hopper");
|
||||
returned.WorkOrderApprovedExposureTotal.Should().Be(450m);
|
||||
data!.PendingExposureTotal.Should().Be(200m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -282,6 +282,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_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId()
|
||||
{
|
||||
|
|
@ -429,66 +471,6 @@ public sealed class UpliftQueueReadTests
|
|||
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_AttachmentCount_CountsActiveDocumentsOnTheDispatch()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
||||
context.VendorCompletionDocuments.AddRange(
|
||||
new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "quote.pdf",
|
||||
StoredFileName = "a.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 512,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "UpliftEvidence",
|
||||
Version = 1
|
||||
},
|
||||
new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "photo.jpg",
|
||||
StoredFileName = "b.bin",
|
||||
ContentType = "image/jpeg",
|
||||
SizeBytes = 2048,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "Completion",
|
||||
Version = 1
|
||||
},
|
||||
new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "old.pdf",
|
||||
StoredFileName = "c.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 128,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "Completion",
|
||||
Version = 1,
|
||||
IsDeleted = true
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single().AttachmentCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
|
||||
{
|
||||
|
|
@ -720,4 +702,138 @@ public sealed class UpliftQueueReadTests
|
|||
tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue();
|
||||
tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- Closed-work-order flag and decider (SH-208) ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_MarksWorkOrderClosedOnlyForTerminalLifecycleStatuses()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-OPEN", "SITE-A", "HVAC");
|
||||
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-DONE", "SITE-B", "HVAC");
|
||||
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-CANCEL", "SITE-C", "HVAC");
|
||||
workOrderA.LifecycleStatus = LifecycleStatus.InProgress;
|
||||
workOrderB.LifecycleStatus = LifecycleStatus.Completed;
|
||||
workOrderC.LifecycleStatus = LifecycleStatus.Canceled;
|
||||
await context.SaveChangesAsync();
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||
var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
|
||||
Request(dispatchB, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
|
||||
Request(dispatchC, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-OPEN").WorkOrderClosed.Should().BeFalse();
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-DONE").WorkOrderClosed.Should().BeTrue();
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-CANCEL").WorkOrderClosed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsDecidedByNameFromTheDecidingUser()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" });
|
||||
await context.SaveChangesAsync();
|
||||
var request = Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2));
|
||||
request.DecidedByUserId = "user-9";
|
||||
context.DispatchUpliftRequests.Add(request);
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
|
||||
}
|
||||
|
||||
// --- Attachment count (SH-207) ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_CountsOnlyNonDeletedUpliftEvidenceAttachments()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.VendorCompletionDocuments.AddRange(
|
||||
Document(dispatch, vendor, workOrder, "quote.pdf"),
|
||||
Document(dispatch, vendor, workOrder, "photo.jpg"),
|
||||
Document(dispatch, vendor, workOrder, "signed-completion.pdf", purpose: "Completion"),
|
||||
Document(dispatch, vendor, workOrder, "deleted-estimate.pdf", isDeleted: true));
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 3, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 200m
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single().AttachmentCount.Should().Be(2);
|
||||
}
|
||||
|
||||
private static VendorCompletionDocument Document(
|
||||
Dispatch dispatch,
|
||||
Vendor vendor,
|
||||
WorkOrder workOrder,
|
||||
string fileName,
|
||||
string purpose = "UpliftEvidence",
|
||||
bool isDeleted = false) => new()
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = fileName,
|
||||
StoredFileName = $"{fileName}.bin",
|
||||
ContentType = "application/octet-stream",
|
||||
SizeBytes = 128,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = purpose,
|
||||
Version = 1,
|
||||
IsDeleted = isDeleted
|
||||
};
|
||||
|
||||
// --- Queue-wide pending exposure (SH-207) ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_PendingExposureTotal_SumsPendingRequestsAcrossTheQueueRegardlessOfFilter()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC");
|
||||
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, 3, 1), requested: 125m),
|
||||
Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 75m),
|
||||
Request(dispatchA, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 999m),
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 3, 5),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 500m,
|
||||
IsDeleted = true
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
// Even when the caller reads the Approved decision log, the header total
|
||||
// still reflects the whole pending queue.
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.PendingExposureTotal.Should().Be(200m);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ namespace Data.SeaHavenIndustries
|
|||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSiteCode { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
public bool WorkOrderClosed { get; set; }
|
||||
public int AttachmentCount { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
public string? RequestedByFirstName { get; set; }
|
||||
public string? RequestedByLastName { get; set; }
|
||||
|
|
@ -106,8 +108,6 @@ namespace Data.SeaHavenIndustries
|
|||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
public int AttachmentCount { get; set; }
|
||||
public bool WorkOrderClosed { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftForWorkOrderData : UpliftForDispatchData
|
||||
|
|
|
|||
|
|
@ -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 decisionUsers
|
||||
from decUser in decisionUsers.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,14 +71,24 @@ 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,
|
||||
// The queue renders the internal display number (InternalWONumber, what the
|
||||
// board shows); WorkerOrderNumber holds the CRM external id on synced
|
||||
// work orders and must not leak into the queue display.
|
||||
WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null,
|
||||
WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null,
|
||||
WorkOrderService = x.wo != null ? x.wo.Service : null,
|
||||
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-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
|
||||
&& (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 =>
|
||||
doc.Purpose == "UpliftEvidence"
|
||||
&& (doc.IsDeleted == null || doc.IsDeleted == false)),
|
||||
RequestedByVendorName = x.u.RequestedByVendorName,
|
||||
RequestedByFirstName = x.reqUser != null ? x.reqUser.FirstName : null,
|
||||
RequestedByLastName = x.reqUser != null ? x.reqUser.LastName : null,
|
||||
|
|
@ -90,12 +109,6 @@ namespace SeaHaven.DataServices.Implementation
|
|||
ExpiresAt = x.u.ExpiresAt,
|
||||
NotificationStatus = x.u.NotificationStatus,
|
||||
NotificationError = x.u.NotificationError,
|
||||
AttachmentCount = _context.VendorCompletionDocuments.Count(document =>
|
||||
document.DispatchId == x.d.Id
|
||||
&& (document.IsDeleted == null || document.IsDeleted == false)),
|
||||
WorkOrderClosed = x.wo != null
|
||||
&& (x.wo.LifecycleStatus == LifecycleStatus.Completed
|
||||
|| x.wo.LifecycleStatus == LifecycleStatus.Canceled)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
|
@ -401,6 +414,19 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return totals.Values.ToList();
|
||||
}
|
||||
|
||||
// SH-207: queue-wide pending exposure for the approvals header. Mirrors the
|
||||
// approved-exposure convention (sum of RequestedNTE) over every non-deleted
|
||||
// Pending request on a non-deleted dispatch, independent of page or filters.
|
||||
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Status == "Pending"
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
|
||||
.SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m;
|
||||
}
|
||||
|
||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
// from the result have no approved exposure.
|
||||
Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
|
||||
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken);
|
||||
// SH-207: queue-wide pending exposure for the approvals header (sum of
|
||||
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
|
||||
Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
|
||||
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
|
|
|
|||
110
SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs
Normal file
110
SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
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
|
||||
{
|
||||
// The queue renders the internal display number; WorkerOrderNumber holds the
|
||||
// CRM external id and must not surface here.
|
||||
InternalWONumber = "WO-LINKED",
|
||||
WorkerOrderNumber = "CRM-EXT-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -444,6 +444,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue