mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 19:32:10 +00:00
Merge remote-tracking branch 'origin/dev' into feat/ab/sh-207-uplift-queue
This commit is contained in:
commit
7b94ab9455
9 changed files with 283 additions and 3 deletions
|
|
@ -117,6 +117,49 @@ public class UpliftControllerTests
|
||||||
nf.Value.Should().BeOfType<Response>();
|
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]
|
[Fact]
|
||||||
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
|
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,35 @@ public sealed class UpliftQueueReadTests
|
||||||
item.Status.Should().Be("Pending");
|
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]
|
[Fact]
|
||||||
public async Task List_RequesterLabelFallsBackToCreatingUserName()
|
public async Task List_RequesterLabelFallsBackToCreatingUserName()
|
||||||
{
|
{
|
||||||
|
|
@ -498,7 +527,9 @@ public sealed class UpliftQueueReadTests
|
||||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||||
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||||
context.DispatchUpliftRequests.AddRange(
|
context.DispatchUpliftRequests.AddRange(
|
||||||
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m),
|
// 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
|
new DispatchUpliftRequest
|
||||||
{
|
{
|
||||||
DispatchId = dispatchA.Id,
|
DispatchId = dispatchA.Id,
|
||||||
|
|
@ -535,7 +566,7 @@ public sealed class UpliftQueueReadTests
|
||||||
RequiredTier = 1,
|
RequiredTier = 1,
|
||||||
RequestedNTE = 999m
|
RequestedNTE = 999m
|
||||||
},
|
},
|
||||||
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m));
|
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m));
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewService(context);
|
var service = NewService(context);
|
||||||
|
|
||||||
|
|
@ -615,6 +646,58 @@ public sealed class UpliftQueueReadTests
|
||||||
total.AutoApprovedTotal.Should().Be(0m);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath()
|
||||||
|
{
|
||||||
|
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, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
|
||||||
|
Request(dispatch, "NoApprovalRequired", 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, "NoApprovalRequired", 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.AutoApprovedTotal.Should().Be(1000m);
|
||||||
|
total.AdminApprovedTotal.Should().Be(0m);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Read authorization (tier roles) ---
|
// --- Read authorization (tier roles) ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public sealed class UpliftServiceRefusedTests
|
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]
|
[Fact]
|
||||||
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
|
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")]
|
[HttpGet("can-approve")]
|
||||||
public IActionResult CanApprove([FromQuery] int tier)
|
public IActionResult CanApprove([FromQuery] int tier)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
PONumber = x.d.PONumber,
|
PONumber = x.d.PONumber,
|
||||||
WorkOrderId = x.d.WorkOrderId,
|
WorkOrderId = x.d.WorkOrderId,
|
||||||
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
|
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,
|
WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null,
|
||||||
WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null,
|
WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null,
|
||||||
WorkOrderService = x.wo != null ? x.wo.Service : null,
|
WorkOrderService = x.wo != null ? x.wo.Service : null,
|
||||||
|
|
@ -242,6 +245,27 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
.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)
|
public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.DispatchUpliftRequests
|
return await _context.DispatchUpliftRequests
|
||||||
|
|
@ -337,6 +361,13 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
DispatchId = g.Key,
|
DispatchId = g.Key,
|
||||||
|
// 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. This applies to both buckets.
|
||||||
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
|
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
|
||||||
.Sum(x => (decimal?)(x.createdby == null
|
.Sum(x => (decimal?)(x.createdby == null
|
||||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, 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);
|
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
|
||||||
// SH-101: server-side join of an uplift request with its linked evidence document.
|
// 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.
|
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,22 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IVendorDocumentStoragePort _documentStorage;
|
private readonly IVendorDocumentStoragePort _documentStorage;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ApprovalsOptions _approvalsOptions;
|
private readonly ApprovalsOptions _approvalsOptions;
|
||||||
|
private readonly IWorkOrderUpliftService? _workOrderUpliftService;
|
||||||
|
|
||||||
public UpliftService(
|
public UpliftService(
|
||||||
IUpliftDataService upliftData,
|
IUpliftDataService upliftData,
|
||||||
IDispatchDataService dispatchData,
|
IDispatchDataService dispatchData,
|
||||||
IVendorDocumentStoragePort documentStorage,
|
IVendorDocumentStoragePort documentStorage,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IOptions<ApprovalsOptions> approvalsOptions)
|
IOptions<ApprovalsOptions> approvalsOptions,
|
||||||
|
IWorkOrderUpliftService? workOrderUpliftService = null)
|
||||||
{
|
{
|
||||||
_upliftData = upliftData;
|
_upliftData = upliftData;
|
||||||
_dispatchData = dispatchData;
|
_dispatchData = dispatchData;
|
||||||
_documentStorage = documentStorage;
|
_documentStorage = documentStorage;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_approvalsOptions = approvalsOptions.Value;
|
_approvalsOptions = approvalsOptions.Value;
|
||||||
|
_workOrderUpliftService = workOrderUpliftService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
||||||
|
|
@ -185,6 +188,41 @@ namespace SeaHaven.Services.Implementation
|
||||||
public Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
public Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
||||||
=> RejectInternalAsync(user, id, note, "reject", 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)
|
private async Task<UpliftDecisionResultDTO> RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(note))
|
if (string.IsNullOrWhiteSpace(note))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||||
// SH-101: canonical reject (alias of deny; writes Rejected).
|
// SH-101: canonical reject (alias of deny; writes Rejected).
|
||||||
Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
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).
|
// SH-101: internal request-changes route (note + tier authorization + audit).
|
||||||
Task<UpliftDecisionResultDTO> RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken);
|
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.
|
// SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request.
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, 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<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<DispatchUpliftRequest?> GetByIdAsync(int id, 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<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, 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> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue