mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
Merge pull request #141 from Sea-Haven-Industries/feat/ab/sh-210-uplift-decisions
SH-210: Route uplift decisions through the work-order flow
This commit is contained in:
commit
0ae64f9ab9
11 changed files with 1152 additions and 5 deletions
|
|
@ -52,6 +52,56 @@ public class UpliftControllerTests
|
|||
envelope.Status.Should().Be("Success");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_PassesStatusAndTierFiltersToService()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new UpliftListResultDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
await controller.List("Approved", 2, 3, 50);
|
||||
|
||||
service.Verify(
|
||||
x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsQueueContractFieldsInEnvelope()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
var item = new UpliftListItemDTO
|
||||
{
|
||||
Id = 7,
|
||||
WorkOrderId = 11,
|
||||
WorkOrderNumber = "WO-77",
|
||||
WorkOrderSite = "SITE-EAST",
|
||||
WorkOrderService = "HVAC",
|
||||
RequestedByName = "Gateway",
|
||||
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 } });
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.List();
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
var data = envelope.Data as UpliftListResultDTO;
|
||||
var returned = data!.Items.Should().ContainSingle().Subject;
|
||||
returned.WorkOrderNumber.Should().Be("WO-77");
|
||||
returned.WorkOrderSite.Should().Be("SITE-EAST");
|
||||
returned.WorkOrderService.Should().Be("HVAC");
|
||||
returned.RequestedByName.Should().Be("Gateway");
|
||||
returned.WorkOrderApprovedExposureTotal.Should().Be(450m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_NotFound_Returns404()
|
||||
{
|
||||
|
|
@ -67,6 +117,49 @@ public class UpliftControllerTests
|
|||
nf.Value.Should().BeOfType<Response>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_WithReason_DelegatesToService()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
"Policy change",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" });
|
||||
|
||||
var controller = NewController(service, "Admin");
|
||||
|
||||
var result = await controller.Revoke(
|
||||
7,
|
||||
new UpliftController.DecisionRequest { Note = "Policy change" });
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.Verify(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
"Policy change",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_WithoutReason_ReturnsBadRequest()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
string.Empty,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("reason required"));
|
||||
|
||||
var controller = NewController(service, "Admin");
|
||||
|
||||
var result = await controller.Revoke(7, null);
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
|
||||
{
|
||||
|
|
|
|||
697
Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs
Normal file
697
Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
// Behavior tests for the approval queue read contract. These exercise the real
|
||||
// service + data-service layers against an in-memory DbContext so that queue
|
||||
// ordering, filters, flattened work-order fields, exposure aggregation, and
|
||||
// per-tier read authorization are validated through the public contract.
|
||||
public sealed class UpliftQueueReadTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ApprovalsOptions NewOptions() => new()
|
||||
{
|
||||
UpliftTier1MaxUsd = 2500m,
|
||||
Tier1Roles = new[] { "Approver" },
|
||||
Tier2Roles = new[] { "Manager" },
|
||||
Tier1NotificationRecipients = new[] { "tier1@example.com" },
|
||||
Tier2NotificationRecipients = new[] { "tier2@example.com" },
|
||||
EscalationRecipients = new[] { "escalate@example.com" }
|
||||
};
|
||||
|
||||
private static UpliftService NewService(ApplicationDbContext context) =>
|
||||
new(new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()));
|
||||
|
||||
private static ClaimsPrincipal UserWithRoles(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "user-42") };
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private static DispatchUpliftRequest Request(
|
||||
Dispatch dispatch,
|
||||
string status,
|
||||
DateTime created,
|
||||
DateTime? decided = null,
|
||||
int tier = 1,
|
||||
decimal requested = 100m,
|
||||
string? reason = null,
|
||||
string? requestedBy = null,
|
||||
string? createdBy = null,
|
||||
decimal? currentNte = null) => new()
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = status,
|
||||
CreatedDate = created,
|
||||
DecidedAt = decided,
|
||||
RequiredTier = tier,
|
||||
RequestedNTE = requested,
|
||||
CurrentNTE = currentNte,
|
||||
VendorReason = reason,
|
||||
RequestedByVendorName = requestedBy,
|
||||
createdby = createdBy,
|
||||
NotificationStatus = "Pending"
|
||||
};
|
||||
|
||||
private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync(
|
||||
ApplicationDbContext context,
|
||||
string number,
|
||||
string site,
|
||||
string service,
|
||||
string vendorName = "Gateway")
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = vendorName, IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = number,
|
||||
WorkerOrderNumber = $"legacy-{number}",
|
||||
SiteCode = site,
|
||||
Service = service,
|
||||
WorkerOrderTitle = "Repair"
|
||||
};
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
return (vendor, workOrder);
|
||||
}
|
||||
|
||||
private static async Task<Dispatch> SeedDispatchAsync(
|
||||
ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number)
|
||||
{
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
DispatchNumber = number,
|
||||
Status = "Completed",
|
||||
NTEAmount = 1000m
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
return dispatch;
|
||||
}
|
||||
|
||||
// --- Ordering ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_PendingStatus_OrdersOldestRequestFirst()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 3)),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
|
||||
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.Select(i => i.RequestedAt).Should().Equal(
|
||||
new DateTime(2026, 3, 1),
|
||||
new DateTime(2026, 3, 2),
|
||||
new DateTime(2026, 3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Select(i => i.DecidedAt).Should().Equal(
|
||||
new DateTime(2026, 3, 20),
|
||||
new DateTime(2026, 3, 15),
|
||||
new DateTime(2026, 3, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Select(i => i.RequestedAt).Should().ContainInOrder(
|
||||
new DateTime(2026, 3, 10),
|
||||
new DateTime(2026, 3, 1));
|
||||
}
|
||||
|
||||
// --- Filters and scoping ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_StatusAndTierFilters_Combine()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Total.Should().Be(1);
|
||||
result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
var deletedDispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
DispatchNumber = "DIS-DELETED",
|
||||
Status = "Completed",
|
||||
IsDeleted = true
|
||||
};
|
||||
context.Dispatches.Add(deletedDispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 3, 2),
|
||||
IsDeleted = true,
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 100m
|
||||
},
|
||||
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Total.Should().Be(1);
|
||||
result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1));
|
||||
}
|
||||
|
||||
// --- Flattened queue fields ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsFlattenedWorkOrderAndRequesterFields()
|
||||
{
|
||||
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-7", FirstName = "Ada", LastName = "Smith" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(
|
||||
dispatch, "Pending", new DateTime(2026, 3, 2),
|
||||
tier: 2, requested: 400m, reason: "Scope grew",
|
||||
requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderId.Should().Be(workOrder.Id);
|
||||
item.WorkOrderNumber.Should().Be("WO-77");
|
||||
item.WorkOrderSite.Should().Be("SITE-EAST");
|
||||
item.WorkOrderService.Should().Be("HVAC");
|
||||
item.VendorCompanyName.Should().Be("Gateway");
|
||||
item.RequestedByName.Should().Be("Gateway");
|
||||
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
|
||||
item.VendorReason.Should().Be("Scope grew");
|
||||
item.Delta.Should().Be(300m);
|
||||
item.Status.Should().Be("Pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
// Synced work orders carry the CRM external id in WorkerOrderNumber; the SH
|
||||
// display number the board renders is stamped on InternalWONumber.
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "10000000001",
|
||||
WorkerOrderNumber = "CRM-EXT-77",
|
||||
SiteCode = "SITE-EAST",
|
||||
Service = "HVAC",
|
||||
WorkerOrderTitle = "Repair"
|
||||
};
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
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);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderNumber.Should().Be("10000000001");
|
||||
item.WorkOrderNumber.Should().NotBe("CRM-EXT-77");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_RequesterLabelFallsBackToCreatingUserName()
|
||||
{
|
||||
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-7", FirstName = "Ada", LastName = "Smith" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(
|
||||
dispatch, "Pending", new DateTime(2026, 3, 2),
|
||||
requestedBy: null, createdBy: "user-7"));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single().RequestedByName.Should().Be("Ada Smith");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsEvidenceAttachmentSummary()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
var evidence = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "quote.pdf",
|
||||
StoredFileName = "evidence.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 512,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "UpliftEvidence",
|
||||
Version = 1
|
||||
};
|
||||
context.VendorCompletionDocuments.Add(evidence);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 3, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 200m,
|
||||
EvidenceDocumentId = evidence.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Single();
|
||||
item.EvidenceDocumentId.Should().Be(evidence.Id);
|
||||
item.EvidenceFileName.Should().Be("quote.pdf");
|
||||
item.EvidenceContentType.Should().Be("application/pdf");
|
||||
item.EvidenceSizeBytes.Should().Be(512);
|
||||
}
|
||||
|
||||
// --- Queue contract fields (SH-208/SH-213) ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle()
|
||||
{
|
||||
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", "Plumbing");
|
||||
workOrderA.LifecycleStatus = LifecycleStatus.Completed;
|
||||
workOrderB.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 (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical");
|
||||
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, 3), decided: new DateTime(2026, 3, 4)),
|
||||
Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue();
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue();
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser()
|
||||
{
|
||||
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.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = "Approved",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
DecidedAt = new DateTime(2026, 3, 2),
|
||||
DecidedByUserId = "user-9",
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 300m,
|
||||
NotificationStatus = "Pending"
|
||||
});
|
||||
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");
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
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.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal rows (createdby null) store the requested new NTE total,
|
||||
// so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350.
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
|
||||
// Work-order-path rows (createdby set) store the requested increment: 90.
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
|
||||
// Non-pending rows never add pending exposure.
|
||||
Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
// Page 1 with a single row still reports the total across ALL pending rows.
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None);
|
||||
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.PendingExposureTotal.Should().Be(740m);
|
||||
}
|
||||
|
||||
// --- Approved-on-WO exposure totals ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A");
|
||||
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B");
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal row (createdby null): stores the requested new NTE total,
|
||||
// so the granted amount is 400 - 100 = 300.
|
||||
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m),
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "NoApprovalRequired",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
RequiredTier = 0,
|
||||
RequestedNTE = 150m
|
||||
},
|
||||
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m),
|
||||
Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m),
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Withdrawn",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
DecidedAt = new DateTime(2026, 3, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 999m
|
||||
},
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Revoked",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
DecidedAt = new DateTime(2026, 3, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 999m
|
||||
},
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Expired",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 999m
|
||||
},
|
||||
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A");
|
||||
itemA.WorkOrderAutoApprovedTotal.Should().Be(150m);
|
||||
itemA.WorkOrderAdminApprovedTotal.Should().Be(300m);
|
||||
itemA.WorkOrderApprovedExposureTotal.Should().Be(450m);
|
||||
|
||||
var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B");
|
||||
itemB.WorkOrderAutoApprovedTotal.Should().Be(0m);
|
||||
itemB.WorkOrderAdminApprovedTotal.Should().Be(75m);
|
||||
itemB.WorkOrderApprovedExposureTotal.Should().Be(75m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(
|
||||
dispatch,
|
||||
"Approved",
|
||||
new DateTime(2026, 3, 1),
|
||||
decided: new DateTime(2026, 3, 2),
|
||||
requested: 400m,
|
||||
currentNte: 100m),
|
||||
Request(
|
||||
dispatch,
|
||||
"Approved",
|
||||
new DateTime(2026, 3, 3),
|
||||
decided: new DateTime(2026, 3, 4),
|
||||
requested: 250m,
|
||||
createdBy: "internal-user",
|
||||
currentNte: 100m));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var exposure = await new UpliftDataService(context)
|
||||
.GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None);
|
||||
|
||||
exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var linkedDispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendorA.Id,
|
||||
DispatchNumber = "DIS-LINKED",
|
||||
Status = "Completed"
|
||||
};
|
||||
context.Dispatches.Add(linkedDispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||
{
|
||||
DispatchId = linkedDispatch.Id,
|
||||
WorkOrderId = workOrderA.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(
|
||||
linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m));
|
||||
await context.SaveChangesAsync();
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
||||
new[] { workOrderA.Id }, CancellationToken.None);
|
||||
|
||||
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject;
|
||||
total.AdminApprovedTotal.Should().Be(60m);
|
||||
total.AutoApprovedTotal.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath()
|
||||
{
|
||||
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.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal rows (createdby null) store the requested new NTE total:
|
||||
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
|
||||
// Work-order-path rows (createdby set) store the granted increment: 200.
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
|
||||
await context.SaveChangesAsync();
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
||||
new[] { workOrder.Id }, CancellationToken.None);
|
||||
|
||||
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
|
||||
// double-count whole NTE totals and report 3500.
|
||||
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
|
||||
total.AdminApprovedTotal.Should().Be(1000m);
|
||||
total.AutoApprovedTotal.Should().Be(0m);
|
||||
}
|
||||
|
||||
// --- Read authorization (tier roles) ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_CanDecide_ReflectsTierRolesForTheCaller()
|
||||
{
|
||||
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.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue();
|
||||
tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse();
|
||||
tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue();
|
||||
tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
|
|||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
|
@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
|
||||
public sealed class UpliftServiceRefusedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow()
|
||||
{
|
||||
var request = new DispatchUpliftRequest
|
||||
{
|
||||
Id = 7,
|
||||
DispatchId = 42,
|
||||
Status = "Approved",
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 900m
|
||||
};
|
||||
var upliftData = new Mock<IUpliftDataService>();
|
||||
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(request);
|
||||
upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(77);
|
||||
var workOrderFlow = new Mock<IWorkOrderUpliftService>();
|
||||
workOrderFlow.Setup(flow => flow.RevokeAsync(
|
||||
77,
|
||||
7,
|
||||
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" });
|
||||
|
||||
var service = new UpliftService(
|
||||
upliftData.Object,
|
||||
Mock.Of<IDispatchDataService>(),
|
||||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||
workOrderFlow.Object);
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
},
|
||||
"test"));
|
||||
|
||||
var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None);
|
||||
|
||||
result.Id.Should().Be(7);
|
||||
result.Status.Should().Be("Revoked");
|
||||
workOrderFlow.Verify(flow => flow.RevokeAsync(
|
||||
77,
|
||||
7,
|
||||
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/revoke")]
|
||||
public async Task<IActionResult> Revoke(
|
||||
int id,
|
||||
[FromBody] DecisionRequest? body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _upliftService.RevokeAsync(
|
||||
User,
|
||||
id,
|
||||
body?.Note ?? string.Empty,
|
||||
cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to revoke this uplift") });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("can-approve")]
|
||||
public IActionResult CanApprove([FromQuery] int tier)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ namespace Data.SeaHavenIndustries
|
|||
public string? PONumber { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? VendorCompanyName { get; set; }
|
||||
// Approval queue read contract: flattened work-order context.
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSiteCode { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
public string? RequestedByFirstName { get; set; }
|
||||
public string? RequestedByLastName { get; set; }
|
||||
public string? DecidedByFirstName { get; set; }
|
||||
public string? DecidedByLastName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
|
|
@ -97,6 +106,8 @@ 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
|
||||
|
|
@ -130,6 +141,16 @@ namespace Data.SeaHavenIndustries
|
|||
public bool EvidenceScanPassed { get; set; }
|
||||
}
|
||||
|
||||
// Approval queue read contract: per-work-order exposure totals. Approved exposure is
|
||||
// the sum of uplift amounts that were auto-approved or admin-approved on the work
|
||||
// order; pending, rejected, cancelled/withdrawn, and revoked requests are excluded.
|
||||
public class WorkOrderUpliftExposureData
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public decimal AutoApprovedTotal { get; set; }
|
||||
public decimal AdminApprovedTotal { get; set; }
|
||||
}
|
||||
|
||||
// SH-101: internal evidence-download projection. Server-side join enforces that the
|
||||
// returned document is the one linked to this specific uplift request and dispatch.
|
||||
public class UpliftEvidenceDownloadData
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
|
|
@ -24,8 +25,15 @@ 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()
|
||||
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()
|
||||
where (u.IsDeleted == null || u.IsDeleted == false)
|
||||
select new { u, d, v, ev };
|
||||
&& (d.IsDeleted == null || d.IsDeleted == false)
|
||||
select new { u, d, v, ev, wo, reqUser, decUser };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(x => x.u.Status == status);
|
||||
|
|
@ -34,8 +42,18 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// Approval queue read contract: the actionable queue (Pending) surfaces the
|
||||
// oldest request first; the decision log (Approved) surfaces the most
|
||||
// recently decided first. Every other read keeps the historical
|
||||
// newest-request-first order. Id is the deterministic tiebreaker.
|
||||
if (string.Equals(status, "Pending", StringComparison.Ordinal))
|
||||
query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id);
|
||||
else if (string.Equals(status, "Approved", StringComparison.Ordinal))
|
||||
query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id);
|
||||
else
|
||||
query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.u.CreatedDate)
|
||||
.Skip((Math.Max(page, 1) - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new UpliftListItemData
|
||||
|
|
@ -46,6 +64,17 @@ namespace SeaHaven.DataServices.Implementation
|
|||
PONumber = x.d.PONumber,
|
||||
WorkOrderId = x.d.WorkOrderId,
|
||||
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,
|
||||
RequestedByVendorName = x.u.RequestedByVendorName,
|
||||
RequestedByFirstName = x.reqUser != null ? x.reqUser.FirstName : null,
|
||||
RequestedByLastName = x.reqUser != null ? x.reqUser.LastName : null,
|
||||
DecidedByFirstName = x.decUser != null ? x.decUser.FirstName : null,
|
||||
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
|
||||
CurrentNTE = x.u.CurrentNTE,
|
||||
RequestedNTE = x.u.RequestedNTE,
|
||||
VendorReason = x.u.VendorReason,
|
||||
|
|
@ -60,13 +89,29 @@ namespace SeaHaven.DataServices.Implementation
|
|||
EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null,
|
||||
ExpiresAt = x.u.ExpiresAt,
|
||||
NotificationStatus = x.u.NotificationStatus,
|
||||
NotificationError = x.u.NotificationError
|
||||
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);
|
||||
|
||||
return (total, items);
|
||||
}
|
||||
|
||||
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.DispatchUpliftRequests
|
||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Status == "Pending")
|
||||
.SumAsync(u => u.createdby == null
|
||||
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
|
||||
: u.RequestedNTE, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
|
|
@ -200,6 +245,27 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int?> GetWorkOrderIdForUpliftAsync(
|
||||
int upliftRequestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var link = await _context.DispatchUpliftRequests
|
||||
.Where(u => u.Id == upliftRequestId
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
|
||||
.Select(u => new
|
||||
{
|
||||
PrimaryWorkOrderId = u.Dispatch!.WorkOrderId,
|
||||
LinkedWorkOrderId = u.Dispatch.DispatchWorkOrders!
|
||||
.Select(dispatchWorkOrder => (int?)dispatchWorkOrder.WorkOrderId)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return link?.PrimaryWorkOrderId ?? link?.LinkedWorkOrderId;
|
||||
}
|
||||
|
||||
public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
|
|
@ -252,6 +318,87 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.SumAsync(u => u.RequestedNTE, cancellationToken);
|
||||
}
|
||||
|
||||
// Approval queue read contract: set-based exposure aggregation. Dispatches map to
|
||||
// work orders through the server-derived linkage (primary work order plus linked
|
||||
// work orders), matching the ForWorkOrder scope; approved amounts are summed per
|
||||
// dispatch in SQL and folded into per-work-order totals in memory (three set-based
|
||||
// round trips, no query-per-work-order).
|
||||
public async Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
|
||||
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var workOrderIdsScope = workOrderIds.Distinct().ToList();
|
||||
if (workOrderIdsScope.Count == 0)
|
||||
return Array.Empty<WorkOrderUpliftExposureData>();
|
||||
|
||||
var primaryPairs = await _context.Dispatches
|
||||
.Where(d => (d.IsDeleted == null || d.IsDeleted == false)
|
||||
&& d.WorkOrderId != null
|
||||
&& workOrderIdsScope.Contains(d.WorkOrderId.Value))
|
||||
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var linkedPairs = await _context.DispatchWorkOrders
|
||||
.Where(l => workOrderIdsScope.Contains(l.WorkOrderId)
|
||||
&& l.Dispatch != null
|
||||
&& (l.Dispatch.IsDeleted == null || l.Dispatch.IsDeleted == false))
|
||||
.Select(l => new { l.DispatchId, l.WorkOrderId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var workOrdersByDispatch = primaryPairs
|
||||
.Concat(linkedPairs)
|
||||
.GroupBy(p => p.DispatchId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.WorkOrderId).ToHashSet());
|
||||
|
||||
if (workOrdersByDispatch.Count == 0)
|
||||
return Array.Empty<WorkOrderUpliftExposureData>();
|
||||
|
||||
var dispatchIds = workOrdersByDispatch.Keys.ToList();
|
||||
var sums = await _context.DispatchUpliftRequests
|
||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& dispatchIds.Contains(u.DispatchId)
|
||||
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
|
||||
.GroupBy(u => u.DispatchId)
|
||||
.Select(g => new
|
||||
{
|
||||
DispatchId = g.Key,
|
||||
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
|
||||
.Sum(x => (decimal?)x.RequestedNTE),
|
||||
// The two creation paths store different meanings in RequestedNTE.
|
||||
// Vendor-portal rows store the requested new NTE total, so the
|
||||
// granted amount is RequestedNTE - CurrentNTE; work-order-path rows
|
||||
// store the granted increment directly. Vendor sessions have no
|
||||
// identity user, so createdby is null only on vendor-portal rows. Summing
|
||||
// granted amounts keeps sequential approvals from double-counting whole
|
||||
// NTE totals.
|
||||
AdminApproved = g.Where(x => x.Status == "Approved")
|
||||
.Sum(x => (decimal?)(x.createdby == null
|
||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
||||
: x.RequestedNTE))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var totals = new Dictionary<int, WorkOrderUpliftExposureData>();
|
||||
foreach (var sum in sums)
|
||||
{
|
||||
if (!workOrdersByDispatch.TryGetValue(sum.DispatchId, out var linkedWorkOrders))
|
||||
continue;
|
||||
|
||||
foreach (var workOrderId in linkedWorkOrders)
|
||||
{
|
||||
if (!totals.TryGetValue(workOrderId, out var total))
|
||||
{
|
||||
total = new WorkOrderUpliftExposureData { WorkOrderId = workOrderId };
|
||||
totals[workOrderId] = total;
|
||||
}
|
||||
|
||||
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
|
||||
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
|
||||
}
|
||||
}
|
||||
|
||||
return totals.Values.ToList();
|
||||
}
|
||||
|
||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
|
||||
// SH-101: server-side join of an uplift request with its linked evidence document.
|
||||
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
||||
|
|
@ -18,6 +20,12 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
// Approval queue read contract: set-based per-work-order exposure totals covering
|
||||
// auto-approved and admin-approved uplift amounts (pending, rejected,
|
||||
// cancelled/withdrawn, and revoked requests are excluded). Work orders absent
|
||||
// from the result have no approved exposure.
|
||||
Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
|
||||
IReadOnlyCollection<int> workOrderIds, 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);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? PONumber { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? VendorCompanyName { get; set; }
|
||||
// Approval queue read contract: flattened work-order context and requester.
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSite { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
public string? RequestedByName { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public decimal Delta { get; set; }
|
||||
|
|
@ -26,6 +32,14 @@ namespace SeaHaven.Services.DTOs
|
|||
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; }
|
||||
// Approved-on-WO exposure totals for this item's work order: auto-approved and
|
||||
// admin-approved amounts; pending, rejected, cancelled/withdrawn, and revoked
|
||||
// requests are excluded.
|
||||
public decimal WorkOrderAutoApprovedTotal { get; set; }
|
||||
public decimal WorkOrderAdminApprovedTotal { get; set; }
|
||||
public decimal WorkOrderApprovedExposureTotal { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftListResultDTO
|
||||
|
|
@ -33,6 +47,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public int Total { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public decimal PendingExposureTotal { get; set; }
|
||||
public IEnumerable<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,24 +15,28 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IVendorDocumentStoragePort _documentStorage;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
private readonly IWorkOrderUpliftService? _workOrderUpliftService;
|
||||
|
||||
public UpliftService(
|
||||
IUpliftDataService upliftData,
|
||||
IDispatchDataService dispatchData,
|
||||
IVendorDocumentStoragePort documentStorage,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ApprovalsOptions> approvalsOptions)
|
||||
IOptions<ApprovalsOptions> approvalsOptions,
|
||||
IWorkOrderUpliftService? workOrderUpliftService = null)
|
||||
{
|
||||
_upliftData = upliftData;
|
||||
_dispatchData = dispatchData;
|
||||
_documentStorage = documentStorage;
|
||||
_timeProvider = timeProvider;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
_workOrderUpliftService = workOrderUpliftService;
|
||||
}
|
||||
|
||||
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken);
|
||||
var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken);
|
||||
|
||||
var mapped = items.Select(r => new UpliftListItemDTO
|
||||
{
|
||||
|
|
@ -42,6 +46,11 @@ namespace SeaHaven.Services.Implementation
|
|||
PONumber = r.PONumber,
|
||||
WorkOrderId = r.WorkOrderId,
|
||||
VendorCompanyName = r.VendorCompanyName,
|
||||
WorkOrderNumber = r.WorkOrderNumber,
|
||||
WorkOrderSite = r.WorkOrderSiteCode,
|
||||
WorkOrderService = r.WorkOrderService,
|
||||
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
|
||||
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
||||
CurrentNTE = r.CurrentNTE,
|
||||
RequestedNTE = r.RequestedNTE,
|
||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
||||
|
|
@ -58,14 +67,37 @@ namespace SeaHaven.Services.Implementation
|
|||
EvidenceSizeBytes = r.EvidenceSizeBytes,
|
||||
ExpiresAt = r.ExpiresAt,
|
||||
NotificationStatus = r.NotificationStatus,
|
||||
NotificationError = r.NotificationError
|
||||
NotificationError = r.NotificationError,
|
||||
AttachmentCount = r.AttachmentCount,
|
||||
WorkOrderClosed = r.WorkOrderClosed
|
||||
}).ToList();
|
||||
|
||||
// Approved-on-WO exposure totals are aggregated once for the whole page
|
||||
// (single set-based data-service call) and then attached per item.
|
||||
var workOrderIds = mapped
|
||||
.Where(i => i.WorkOrderId.HasValue)
|
||||
.Select(i => i.WorkOrderId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var exposureByWorkOrder = (await _upliftData
|
||||
.GetApprovedExposureForWorkOrdersAsync(workOrderIds, cancellationToken))
|
||||
.ToDictionary(e => e.WorkOrderId);
|
||||
foreach (var item in mapped)
|
||||
{
|
||||
if (!item.WorkOrderId.HasValue) continue;
|
||||
if (!exposureByWorkOrder.TryGetValue(item.WorkOrderId.Value, out var exposure)) continue;
|
||||
|
||||
item.WorkOrderAutoApprovedTotal = exposure.AutoApprovedTotal;
|
||||
item.WorkOrderAdminApprovedTotal = exposure.AdminApprovedTotal;
|
||||
item.WorkOrderApprovedExposureTotal = exposure.AutoApprovedTotal + exposure.AdminApprovedTotal;
|
||||
}
|
||||
|
||||
return new UpliftListResultDTO
|
||||
{
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
PendingExposureTotal = pendingExposureTotal,
|
||||
Items = mapped
|
||||
};
|
||||
}
|
||||
|
|
@ -156,6 +188,41 @@ namespace SeaHaven.Services.Implementation
|
|||
public Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
||||
=> RejectInternalAsync(user, id, note, "reject", cancellationToken);
|
||||
|
||||
public async Task<UpliftDecisionResultDTO> RevokeAsync(
|
||||
ClaimsPrincipal user,
|
||||
int id,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
throw new InvalidOperationException("A reason is required when revoking an approved uplift");
|
||||
|
||||
var request = await _upliftData.GetByIdAsync(id, cancellationToken);
|
||||
if (request == null)
|
||||
throw new KeyNotFoundException("Uplift request not found");
|
||||
if (!string.Equals(UpliftStatus.ToCanonical(request.Status), UpliftStatus.Approved, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"Cannot revoke a '{UpliftStatus.ToCanonical(request.Status)}' uplift request");
|
||||
if (!user.IsInRole("Admin"))
|
||||
throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift");
|
||||
if (_workOrderUpliftService == null)
|
||||
throw new InvalidOperationException("The work-order uplift flow is unavailable");
|
||||
|
||||
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(id, cancellationToken);
|
||||
if (!workOrderId.HasValue)
|
||||
throw new KeyNotFoundException("Work order not found");
|
||||
|
||||
var revoked = await _workOrderUpliftService.RevokeAsync(
|
||||
workOrderId.Value,
|
||||
id,
|
||||
new RevokeWorkOrderUpliftRequestDto { Reason = reason.Trim() },
|
||||
user,
|
||||
cancellationToken);
|
||||
if (revoked == null)
|
||||
throw new KeyNotFoundException("Work order not found");
|
||||
|
||||
return new UpliftDecisionResultDTO { Id = revoked.Id, Status = UpliftStatus.Revoked };
|
||||
}
|
||||
|
||||
private async Task<UpliftDecisionResultDTO> RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
|
|
@ -275,6 +342,19 @@ namespace SeaHaven.Services.Implementation
|
|||
return false;
|
||||
}
|
||||
|
||||
// Approval queue read contract: the requester label prefers the recorded
|
||||
// requester (vendor or internal display name captured at creation) and falls
|
||||
// back to the creating user's name resolved server-side.
|
||||
private static string? ResolveRequestedByName(string? vendorName, string? firstName, string? lastName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(vendorName))
|
||||
return vendorName;
|
||||
|
||||
var composed = string.Join(" ", new[] { firstName, lastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
|
||||
return composed.Length > 0 ? composed : null;
|
||||
}
|
||||
|
||||
private string[] RolesForTier(int requiredTier) => requiredTier switch
|
||||
{
|
||||
1 => _approvalsOptions.Tier1Roles,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||
// SH-101: canonical reject (alias of deny; writes Rejected).
|
||||
Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||
Task<UpliftDecisionResultDTO> RevokeAsync(ClaimsPrincipal user, int id, string reason, CancellationToken cancellationToken);
|
||||
// SH-101: internal request-changes route (note + tier authorization + audit).
|
||||
Task<UpliftDecisionResultDTO> RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken);
|
||||
// SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request.
|
||||
|
|
|
|||
|
|
@ -431,16 +431,19 @@ public class WorkOrderBoardCancelServiceTests
|
|||
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
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<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