mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 09:02:10 +00:00
Merge 2f4b783615 into d3b6ba01c7
This commit is contained in:
commit
4ce68ed651
34 changed files with 5620 additions and 64 deletions
|
|
@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
@ -36,7 +37,11 @@ public sealed class UpliftAdminApprovalTests
|
||||||
new DispatchDataService(context),
|
new DispatchDataService(context),
|
||||||
new NoopDocumentStorage(),
|
new NoopDocumentStorage(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()));
|
Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()),
|
||||||
|
accountResolver: new WorkOrderAccountResolver(
|
||||||
|
new AccountDataService(context),
|
||||||
|
new LocationDataService(context)),
|
||||||
|
detailData: new WorkOrderDetailDataService(context));
|
||||||
|
|
||||||
private static async Task<Dispatch> SeedPendingAsync(ApplicationDbContext context, int requiredTier)
|
private static async Task<Dispatch> SeedPendingAsync(ApplicationDbContext context, int requiredTier)
|
||||||
{
|
{
|
||||||
|
|
@ -158,7 +163,11 @@ public sealed class UpliftAdminApprovalTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewService(context);
|
var service = NewService(context);
|
||||||
|
|
||||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None);
|
var admin = UserWithRoles("Admin");
|
||||||
|
((ClaimsIdentity)admin.Identity!).AddClaim(
|
||||||
|
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
|
||||||
|
|
||||||
|
var result = await service.GetEvidenceForDownloadAsync(admin, 1, CancellationToken.None);
|
||||||
|
|
||||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||||
result.FileName.Should().Be("invoice.pdf");
|
result.FileName.Should().Be("invoice.pdf");
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@ public class UpliftControllerTests
|
||||||
public async Task DownloadEvidence_NotFound_Returns404()
|
public async Task DownloadEvidence_NotFound_Returns404()
|
||||||
{
|
{
|
||||||
var service = new Mock<IUpliftService>();
|
var service = new Mock<IUpliftService>();
|
||||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>()))
|
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound());
|
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound());
|
||||||
var controller = NewController(service);
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
|
@ -335,7 +335,7 @@ public class UpliftControllerTests
|
||||||
public async Task DownloadEvidence_Locked_Returns423()
|
public async Task DownloadEvidence_Locked_Returns423()
|
||||||
{
|
{
|
||||||
var service = new Mock<IUpliftService>();
|
var service = new Mock<IUpliftService>();
|
||||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked());
|
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked());
|
||||||
var controller = NewController(service);
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
|
@ -351,7 +351,7 @@ public class UpliftControllerTests
|
||||||
public async Task DownloadEvidence_Available_ReturnsFile()
|
public async Task DownloadEvidence_Available_ReturnsFile()
|
||||||
{
|
{
|
||||||
var service = new Mock<IUpliftService>();
|
var service = new Mock<IUpliftService>();
|
||||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf"));
|
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf"));
|
||||||
var controller = NewController(service);
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
|
@ -364,7 +364,7 @@ public class UpliftControllerTests
|
||||||
public async Task DownloadEvidence_Forbidden_ReturnsSanitized403()
|
public async Task DownloadEvidence_Forbidden_ReturnsSanitized403()
|
||||||
{
|
{
|
||||||
var service = new Mock<IUpliftService>();
|
var service = new Mock<IUpliftService>();
|
||||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||||
.ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy"));
|
.ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy"));
|
||||||
var controller = NewController(service);
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,8 @@ public sealed class UpliftRevokeEndpointRulesTests
|
||||||
new TeamPermissionOverrideDataService(context),
|
new TeamPermissionOverrideDataService(context),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()));
|
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||||
|
new VendorDocumentDataService(context));
|
||||||
|
|
||||||
private static async Task<IActionResult> RevokeAsync(
|
private static async Task<IActionResult> RevokeAsync(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
|
|
@ -136,6 +137,7 @@ public sealed class UpliftRevokeEndpointRulesTests
|
||||||
Mock.Of<IWorkOrderDetailService>(),
|
Mock.Of<IWorkOrderDetailService>(),
|
||||||
Mock.Of<IWorkOrderCommentService>(),
|
Mock.Of<IWorkOrderCommentService>(),
|
||||||
workOrderFlow,
|
workOrderFlow,
|
||||||
|
Mock.Of<IWorkOrderUpliftEvidenceService>(),
|
||||||
Mock.Of<ILogger<WorkOrderDetailController>>())
|
Mock.Of<ILogger<WorkOrderDetailController>>())
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using SeaHaven.DataServices.Implementation;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
@ -128,13 +129,39 @@ public sealed class UpliftWorkflowTests
|
||||||
Version = 1
|
Version = 1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static ClaimsPrincipal WorkOrderViewer(params string[] roles)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, "user-42"),
|
||||||
|
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
||||||
|
};
|
||||||
|
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal OtherAccountUser()
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, "user-99"),
|
||||||
|
new(SeaHavenClaimTypes.AccountId, "99"),
|
||||||
|
new(ClaimTypes.Role, "Admin"),
|
||||||
|
};
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||||
|
}
|
||||||
|
|
||||||
private static UpliftService NewUpliftService(
|
private static UpliftService NewUpliftService(
|
||||||
ApplicationDbContext context, IVendorDocumentStoragePort storage) =>
|
ApplicationDbContext context, IVendorDocumentStoragePort storage) =>
|
||||||
new(new UpliftDataService(context),
|
new(new UpliftDataService(context),
|
||||||
new DispatchDataService(context),
|
new DispatchDataService(context),
|
||||||
storage,
|
storage,
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Microsoft.Extensions.Options.Options.Create(NewOptions()));
|
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
||||||
|
accountResolver: new WorkOrderAccountResolver(
|
||||||
|
new AccountDataService(context),
|
||||||
|
new LocationDataService(context)),
|
||||||
|
detailData: new WorkOrderDetailDataService(context));
|
||||||
|
|
||||||
private static VendorPortalService NewPortalService(
|
private static VendorPortalService NewPortalService(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
|
|
@ -910,7 +937,7 @@ public sealed class UpliftWorkflowTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewUpliftService(context, storage);
|
var service = NewUpliftService(context, storage);
|
||||||
|
|
||||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||||
|
|
||||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||||
result.ContentType.Should().Be("application/pdf");
|
result.ContentType.Should().Be("application/pdf");
|
||||||
|
|
@ -918,30 +945,139 @@ public sealed class UpliftWorkflowTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetEvidenceForDownload_ThrowsForbidden_WhenUserLacksRequiredTier()
|
public async Task GetEvidenceForDownload_ReturnsOk_WhenTierZeroAndCallerCanSeeWorkOrder()
|
||||||
{
|
{
|
||||||
using var context = NewContext();
|
using var context = NewContext();
|
||||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||||
|
var storage = new FakeDocumentStorage();
|
||||||
|
await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
{
|
{
|
||||||
DispatchId = dispatch.Id,
|
DispatchId = dispatch.Id,
|
||||||
CurrentNTE = 1000m,
|
CurrentNTE = 1000m,
|
||||||
RequestedNTE = 4000m,
|
RequestedNTE = 200m,
|
||||||
VendorReason = "reason",
|
VendorReason = "reason",
|
||||||
Status = UpliftStatus.Pending,
|
Status = UpliftStatus.NoApprovalRequired,
|
||||||
RequiredTier = 2,
|
RequiredTier = 0,
|
||||||
|
EvidenceDocumentId = 1,
|
||||||
|
CreatedDate = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewUpliftService(context, storage);
|
||||||
|
|
||||||
|
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Dispatcher"), 1, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||||
|
result.FileName.Should().Be("invoice.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvidenceForDownload_ThrowsForbidden_WhenWorkOrderIsOutsideAccount()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||||
|
workOrder.AccountId = 1;
|
||||||
|
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
CurrentNTE = 1000m,
|
||||||
|
RequestedNTE = 200m,
|
||||||
|
VendorReason = "reason",
|
||||||
|
Status = UpliftStatus.NoApprovalRequired,
|
||||||
|
RequiredTier = 0,
|
||||||
EvidenceDocumentId = 1,
|
EvidenceDocumentId = 1,
|
||||||
CreatedDate = DateTime.UtcNow
|
CreatedDate = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||||
|
|
||||||
var act = () => service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
var act = () => service.GetEvidenceForDownloadAsync(OtherAccountUser(), 1, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UpliftForbiddenException>();
|
await act.Should().ThrowAsync<UpliftForbiddenException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvidenceForDownload_ReturnsOk_ForASecondLinkedDocument()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||||
|
var storage = new FakeDocumentStorage();
|
||||||
|
await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||||
|
await storage.SaveAsync(vendor.Id, dispatch.Id, "photo.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||||
|
var quote = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||||
|
quote.Id = 1;
|
||||||
|
var photo = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||||
|
photo.Id = 2;
|
||||||
|
photo.OriginalFileName = "photo.jpg";
|
||||||
|
photo.StoredFileName = "photo.bin";
|
||||||
|
photo.ContentType = "image/jpeg";
|
||||||
|
context.VendorCompletionDocuments.AddRange(quote, photo);
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
CurrentNTE = 1000m,
|
||||||
|
RequestedNTE = 200m,
|
||||||
|
VendorReason = "reason",
|
||||||
|
Status = UpliftStatus.NoApprovalRequired,
|
||||||
|
RequiredTier = 0,
|
||||||
|
EvidenceDocumentId = 1,
|
||||||
|
CreatedDate = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument
|
||||||
|
{
|
||||||
|
UpliftRequestId = 1,
|
||||||
|
DocumentId = 2
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewUpliftService(context, storage);
|
||||||
|
|
||||||
|
var result = await service.GetEvidenceForDownloadAsync(
|
||||||
|
WorkOrderViewer("Dispatcher"),
|
||||||
|
1,
|
||||||
|
CancellationToken.None,
|
||||||
|
documentId: 2);
|
||||||
|
|
||||||
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||||
|
result.FileName.Should().Be("photo.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenDocumentIsNotLinked()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||||
|
var linked = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||||
|
linked.Id = 1;
|
||||||
|
var stranger = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||||
|
stranger.Id = 2;
|
||||||
|
stranger.StoredFileName = "other.bin";
|
||||||
|
context.VendorCompletionDocuments.AddRange(linked, stranger);
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
CurrentNTE = 1000m,
|
||||||
|
RequestedNTE = 200m,
|
||||||
|
VendorReason = "reason",
|
||||||
|
Status = UpliftStatus.Pending,
|
||||||
|
RequiredTier = 1,
|
||||||
|
EvidenceDocumentId = 1,
|
||||||
|
CreatedDate = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||||
|
|
||||||
|
var result = await service.GetEvidenceForDownloadAsync(
|
||||||
|
WorkOrderViewer("Approver"),
|
||||||
|
1,
|
||||||
|
CancellationToken.None,
|
||||||
|
documentId: 2);
|
||||||
|
|
||||||
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence()
|
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence()
|
||||||
{
|
{
|
||||||
|
|
@ -961,7 +1097,7 @@ public sealed class UpliftWorkflowTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||||
|
|
||||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||||
|
|
||||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||||
}
|
}
|
||||||
|
|
@ -986,7 +1122,7 @@ public sealed class UpliftWorkflowTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||||
|
|
||||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||||
|
|
||||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked);
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked);
|
||||||
}
|
}
|
||||||
|
|
@ -1011,7 +1147,7 @@ public sealed class UpliftWorkflowTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||||
|
|
||||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||||
|
|
||||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||||
}
|
}
|
||||||
|
|
@ -1042,7 +1178,7 @@ public sealed class UpliftWorkflowTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||||
|
|
||||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||||
|
|
||||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
|
||||||
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
||||||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||||
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 53 routes
|
/// duplication is collapsed here, so this is the distinct action-relative contract. 56 routes
|
||||||
/// come from 52 actions (Editworkorder binds two routes).
|
/// come from 55 actions (Editworkorder binds two routes).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
|
@ -67,6 +67,7 @@ public class WorkOrderRouteContractTests
|
||||||
"GET {id:int}/comments",
|
"GET {id:int}/comments",
|
||||||
"GET {id:int}/detail",
|
"GET {id:int}/detail",
|
||||||
"GET {id:int}/uplifts",
|
"GET {id:int}/uplifts",
|
||||||
|
"GET {id:int}/uplift-evidence/{documentId:int}",
|
||||||
"GET {id:int}/media",
|
"GET {id:int}/media",
|
||||||
"GET {id:int}/media/{mediaId:int}/content",
|
"GET {id:int}/media/{mediaId:int}/content",
|
||||||
"PATCH {id:int}/board",
|
"PATCH {id:int}/board",
|
||||||
|
|
@ -93,6 +94,7 @@ public class WorkOrderRouteContractTests
|
||||||
"POST {id:int}/completion-doc",
|
"POST {id:int}/completion-doc",
|
||||||
"POST {id:int}/media",
|
"POST {id:int}/media",
|
||||||
"POST {id:int}/uplifts",
|
"POST {id:int}/uplifts",
|
||||||
|
"POST {id:int}/uplift-evidence",
|
||||||
"POST {id:int}/uplifts/{upliftId:int}/cancel",
|
"POST {id:int}/uplifts/{upliftId:int}/cancel",
|
||||||
"POST {id:int}/uplifts/{upliftId:int}/revoke",
|
"POST {id:int}/uplifts/{upliftId:int}/revoke",
|
||||||
"PUT completion-templates/{id:int}",
|
"PUT completion-templates/{id:int}",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public sealed class WorkOrderUpliftControllerTests
|
||||||
Mock.Of<IWorkOrderDetailService>(),
|
Mock.Of<IWorkOrderDetailService>(),
|
||||||
Mock.Of<IWorkOrderCommentService>(),
|
Mock.Of<IWorkOrderCommentService>(),
|
||||||
upliftService.Object,
|
upliftService.Object,
|
||||||
|
Mock.Of<IWorkOrderUpliftEvidenceService>(),
|
||||||
Mock.Of<ILogger<WorkOrderDetailController>>());
|
Mock.Of<ILogger<WorkOrderDetailController>>());
|
||||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "dispatcher-1") };
|
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "dispatcher-1") };
|
||||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,8 @@ public sealed class WorkOrderUpliftServiceConflictTests
|
||||||
UpliftTier1MaxUsd = 2500m,
|
UpliftTier1MaxUsd = 2500m,
|
||||||
Tier1Roles = new[] { "Approver" },
|
Tier1Roles = new[] { "Approver" },
|
||||||
Tier2Roles = new[] { "Manager" },
|
Tier2Roles = new[] { "Manager" },
|
||||||
}));
|
}),
|
||||||
|
new Mock<IVendorDocumentDataService>().Object);
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
|
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
|
||||||
"test"));
|
"test"));
|
||||||
|
|
|
||||||
|
|
@ -167,11 +167,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
// document id is accepted from the client. 404 covers missing request/evidence and
|
// document id is accepted from the client. 404 covers missing request/evidence and
|
||||||
// any non-UpliftEvidence document; 423 covers a scan that has not Passed.
|
// any non-UpliftEvidence document; 423 covers a scan that has not Passed.
|
||||||
[HttpGet("{id:int}/evidence")]
|
[HttpGet("{id:int}/evidence")]
|
||||||
public async Task<IActionResult> DownloadEvidence(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> DownloadEvidence(
|
||||||
|
int id,
|
||||||
|
[FromQuery] int? documentId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _upliftService.GetEvidenceForDownloadAsync(User, id, cancellationToken);
|
var result = await _upliftService.GetEvidenceForDownloadAsync(
|
||||||
|
User,
|
||||||
|
id,
|
||||||
|
cancellationToken,
|
||||||
|
documentId);
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
|
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
private readonly IWorkOrderDetailService _workOrderDetailService;
|
private readonly IWorkOrderDetailService _workOrderDetailService;
|
||||||
private readonly IWorkOrderCommentService _workOrderCommentService;
|
private readonly IWorkOrderCommentService _workOrderCommentService;
|
||||||
private readonly IWorkOrderUpliftService _workOrderUpliftService;
|
private readonly IWorkOrderUpliftService _workOrderUpliftService;
|
||||||
|
private readonly IWorkOrderUpliftEvidenceService _workOrderUpliftEvidenceService;
|
||||||
private readonly ILogger<WorkOrderDetailController> _logger;
|
private readonly ILogger<WorkOrderDetailController> _logger;
|
||||||
|
|
||||||
public WorkOrderDetailController(
|
public WorkOrderDetailController(
|
||||||
IWorkOrderDetailService workOrderDetailService,
|
IWorkOrderDetailService workOrderDetailService,
|
||||||
IWorkOrderCommentService workOrderCommentService,
|
IWorkOrderCommentService workOrderCommentService,
|
||||||
IWorkOrderUpliftService workOrderUpliftService,
|
IWorkOrderUpliftService workOrderUpliftService,
|
||||||
|
IWorkOrderUpliftEvidenceService workOrderUpliftEvidenceService,
|
||||||
ILogger<WorkOrderDetailController> logger)
|
ILogger<WorkOrderDetailController> logger)
|
||||||
{
|
{
|
||||||
_workOrderDetailService = workOrderDetailService;
|
_workOrderDetailService = workOrderDetailService;
|
||||||
_workOrderCommentService = workOrderCommentService;
|
_workOrderCommentService = workOrderCommentService;
|
||||||
_workOrderUpliftService = workOrderUpliftService;
|
_workOrderUpliftService = workOrderUpliftService;
|
||||||
|
_workOrderUpliftEvidenceService = workOrderUpliftEvidenceService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,6 +188,53 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/uplift-evidence")]
|
||||||
|
[RequestSizeLimit(10_000_000)]
|
||||||
|
public async Task<IActionResult> UploadUpliftEvidence(
|
||||||
|
int id,
|
||||||
|
[FromForm] IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uploaded = await _workOrderUpliftEvidenceService.UploadAsync(id, file, User, cancellationToken);
|
||||||
|
if (uploaded == null)
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||||
|
return Ok(new DataResponse { Status = "Success", Data = uploaded });
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be uploaded") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/uplift-evidence/{documentId:int}")]
|
||||||
|
public async Task<IActionResult> GetUpliftEvidenceStatus(
|
||||||
|
int id,
|
||||||
|
int documentId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var status = await _workOrderUpliftEvidenceService.GetStatusAsync(id, documentId, User, cancellationToken);
|
||||||
|
if (status == null)
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Evidence document not found." });
|
||||||
|
return Ok(new DataResponse { Status = "Success", Data = status });
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The evidence file could not be read") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/uplifts/{upliftId:int}/cancel")]
|
[HttpPost("{id:int}/uplifts/{upliftId:int}/cancel")]
|
||||||
public async Task<IActionResult> CancelUplift(int id, int upliftId, CancellationToken cancellationToken)
|
public async Task<IActionResult> CancelUplift(int id, int upliftId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using Api.SeaHavenIndustries.Observability;
|
using Api.SeaHavenIndustries.Observability;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
using Sentry;
|
using Sentry;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Helper
|
namespace Api.SeaHavenIndustries.Helper
|
||||||
|
|
@ -11,17 +13,20 @@ namespace Api.SeaHavenIndustries.Helper
|
||||||
private readonly IWebHostEnvironment _environment;
|
private readonly IWebHostEnvironment _environment;
|
||||||
private readonly ILogger<VendorDocumentScanWorker> _logger;
|
private readonly ILogger<VendorDocumentScanWorker> _logger;
|
||||||
private readonly IHub _sentryHub;
|
private readonly IHub _sentryHub;
|
||||||
|
private readonly bool _passWhenScannerUnavailable;
|
||||||
|
|
||||||
public VendorDocumentScanWorker(
|
public VendorDocumentScanWorker(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
ILogger<VendorDocumentScanWorker> logger,
|
ILogger<VendorDocumentScanWorker> logger,
|
||||||
IHub sentryHub)
|
IHub sentryHub,
|
||||||
|
IOptions<VendorDocumentsOptions> documentOptions)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_sentryHub = sentryHub;
|
_sentryHub = sentryHub;
|
||||||
|
_passWhenScannerUnavailable = documentOptions.Value.PassWhenScannerUnavailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
|
@ -54,14 +59,16 @@ namespace Api.SeaHavenIndustries.Helper
|
||||||
{
|
{
|
||||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||||
var result = await scanner.ScanAsync(path, cancellationToken);
|
var result = await scanner.ScanAsync(path, cancellationToken);
|
||||||
if (result == DocumentScanResult.Unavailable)
|
if (result == DocumentScanResult.Unavailable && !_passWhenScannerUnavailable)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
|
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
|
var passed = result == DocumentScanResult.Passed
|
||||||
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
|
|| (result == DocumentScanResult.Unavailable && _passWhenScannerUnavailable);
|
||||||
|
document.ScanStatus = passed ? "Passed" : "Rejected";
|
||||||
|
document.ReviewStatus = passed ? "Processing" : "Rejected";
|
||||||
document.RejectionReason = result == DocumentScanResult.Infected
|
document.RejectionReason = result == DocumentScanResult.Infected
|
||||||
? "The upload failed malware scanning."
|
? "The upload failed malware scanning."
|
||||||
: document.RejectionReason;
|
: document.RejectionReason;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
|
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
|
||||||
"ApiKey": "${SENDGRID_API_KEY}"
|
"ApiKey": "${SENDGRID_API_KEY}"
|
||||||
},
|
},
|
||||||
|
"VendorDocuments": {
|
||||||
|
"PassWhenScannerUnavailable": true
|
||||||
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"JWT": {
|
"JWT": {
|
||||||
"ValidAudience": "http://localhost:4200",
|
"ValidAudience": "http://localhost:4200",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,17 @@ namespace Data.SeaHavenIndustries
|
||||||
.Property(u => u.RowVersion)
|
.Property(u => u.RowVersion)
|
||||||
.IsRowVersion();
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Entity<DispatchUpliftRequestDocument>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(link => new { link.UpliftRequestId, link.DocumentId });
|
||||||
|
entity.HasOne(link => link.UpliftRequest)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(link => link.UpliftRequestId);
|
||||||
|
entity.HasOne(link => link.Document)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(link => link.DocumentId);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<WorkOrderFieldLock>()
|
builder.Entity<WorkOrderFieldLock>()
|
||||||
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
|
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
@ -397,6 +408,7 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
|
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
|
||||||
public DbSet<DispatchSignoff> DispatchSignoffs { get; set; }
|
public DbSet<DispatchSignoff> DispatchSignoffs { get; set; }
|
||||||
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
||||||
|
public DbSet<DispatchUpliftRequestDocument> DispatchUpliftRequestDocuments { get; set; }
|
||||||
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
|
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
|
||||||
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
|
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
|
||||||
public DbSet<Service> Services { get; set; }
|
public DbSet<Service> Services { get; set; }
|
||||||
|
|
|
||||||
4392
Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs
generated
Normal file
4392
Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,50 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SH396_UpliftEvidenceLinks : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DispatchUpliftRequestDocuments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UpliftRequestId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
DocumentId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DispatchUpliftRequestDocuments", x => new { x.UpliftRequestId, x.DocumentId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DispatchUpliftRequestDocuments_DispatchUpliftRequests_UpliftRequestId",
|
||||||
|
column: x => x.UpliftRequestId,
|
||||||
|
principalTable: "DispatchUpliftRequests",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DispatchUpliftRequestDocuments_VendorCompletionDocuments_DocumentId",
|
||||||
|
column: x => x.DocumentId,
|
||||||
|
principalTable: "VendorCompletionDocuments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DispatchUpliftRequestDocuments_DocumentId",
|
||||||
|
table: "DispatchUpliftRequestDocuments",
|
||||||
|
column: "DocumentId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DispatchUpliftRequestDocuments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1111,6 +1111,21 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("DispatchUpliftRequests");
|
b.ToTable("DispatchUpliftRequests");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequestDocument", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("UpliftRequestId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("DocumentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("UpliftRequestId", "DocumentId");
|
||||||
|
|
||||||
|
b.HasIndex("DocumentId");
|
||||||
|
|
||||||
|
b.ToTable("DispatchUpliftRequestDocuments");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -3702,6 +3717,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("EvidenceDocument");
|
b.Navigation("EvidenceDocument");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequestDocument", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "Document")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DocumentId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.DispatchUpliftRequest", "UpliftRequest")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UpliftRequestId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Document");
|
||||||
|
|
||||||
|
b.Navigation("UpliftRequest");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
public class DispatchUpliftRequestDocument
|
||||||
|
{
|
||||||
|
public int UpliftRequestId { get; set; }
|
||||||
|
[ForeignKey(nameof(UpliftRequestId))]
|
||||||
|
public virtual DispatchUpliftRequest? UpliftRequest { get; set; }
|
||||||
|
|
||||||
|
public int DocumentId { get; set; }
|
||||||
|
[ForeignKey(nameof(DocumentId))]
|
||||||
|
public virtual VendorCompletionDocument? Document { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -121,9 +121,16 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? NotificationError { get; set; }
|
public string? NotificationError { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UpliftEvidenceFileData
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
public class UpliftForWorkOrderData : UpliftForDispatchData
|
public class UpliftForWorkOrderData : UpliftForDispatchData
|
||||||
{
|
{
|
||||||
public string? CreatedByUserId { get; set; }
|
public string? CreatedByUserId { get; set; }
|
||||||
|
public List<UpliftEvidenceFileData> Attachments { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpliftForDispatchData
|
public class UpliftForDispatchData
|
||||||
|
|
|
||||||
|
|
@ -302,25 +302,100 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
// with the DispatchId equality filter enforces server-side request/document linkage:
|
// with the DispatchId equality filter enforces server-side request/document linkage:
|
||||||
// a row is returned only when the document is the one linked to this exact request
|
// a row is returned only when the document is the one linked to this exact request
|
||||||
// and dispatch. Soft-deleted requests/documents never resolve.
|
// and dispatch. Soft-deleted requests/documents never resolve.
|
||||||
public async Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken)
|
public async Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(
|
||||||
|
int upliftRequestId,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int? documentId = null)
|
||||||
{
|
{
|
||||||
return await (from u in _context.DispatchUpliftRequests
|
var request = await _context.DispatchUpliftRequests
|
||||||
where u.Id == upliftRequestId && (u.IsDeleted == null || u.IsDeleted == false)
|
.AsNoTracking()
|
||||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id
|
.FirstOrDefaultAsync(
|
||||||
where ev.DispatchId == u.DispatchId && (ev.IsDeleted == null || ev.IsDeleted == false)
|
uplift => uplift.Id == upliftRequestId
|
||||||
select new UpliftEvidenceDownloadData
|
&& (uplift.IsDeleted == null || uplift.IsDeleted == false),
|
||||||
{
|
cancellationToken);
|
||||||
Id = u.Id,
|
if (request == null)
|
||||||
DispatchId = u.DispatchId,
|
return null;
|
||||||
VendorId = ev.VendorId,
|
|
||||||
RequiredTier = u.RequiredTier,
|
var resolvedDocumentId = documentId ?? request.EvidenceDocumentId;
|
||||||
EvidenceDocumentId = u.EvidenceDocumentId,
|
if (resolvedDocumentId == null)
|
||||||
StoredFileName = ev.StoredFileName,
|
return null;
|
||||||
OriginalFileName = ev.OriginalFileName,
|
|
||||||
ContentType = ev.ContentType,
|
var linkedToRequest = resolvedDocumentId == request.EvidenceDocumentId
|
||||||
Purpose = ev.Purpose,
|
|| await _context.DispatchUpliftRequestDocuments.AnyAsync(
|
||||||
ScanStatus = ev.ScanStatus
|
link => link.UpliftRequestId == request.Id && link.DocumentId == resolvedDocumentId,
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
cancellationToken);
|
||||||
|
if (!linkedToRequest)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var document = await _context.VendorCompletionDocuments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
candidate => candidate.Id == resolvedDocumentId
|
||||||
|
&& candidate.DispatchId == request.DispatchId
|
||||||
|
&& (candidate.IsDeleted == null || candidate.IsDeleted == false),
|
||||||
|
cancellationToken);
|
||||||
|
if (document == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new UpliftEvidenceDownloadData
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
DispatchId = request.DispatchId,
|
||||||
|
VendorId = document.VendorId,
|
||||||
|
RequiredTier = request.RequiredTier,
|
||||||
|
EvidenceDocumentId = document.Id,
|
||||||
|
StoredFileName = document.StoredFileName,
|
||||||
|
OriginalFileName = document.OriginalFileName,
|
||||||
|
ContentType = document.ContentType,
|
||||||
|
Purpose = document.Purpose,
|
||||||
|
ScanStatus = document.ScanStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<int, IReadOnlyList<UpliftEvidenceFileData>>> GetEvidenceFilesAsync(
|
||||||
|
IReadOnlyCollection<int> upliftRequestIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (upliftRequestIds.Count == 0)
|
||||||
|
return new Dictionary<int, IReadOnlyList<UpliftEvidenceFileData>>();
|
||||||
|
|
||||||
|
var rows = await (
|
||||||
|
from link in _context.DispatchUpliftRequestDocuments.AsNoTracking()
|
||||||
|
where upliftRequestIds.Contains(link.UpliftRequestId)
|
||||||
|
join document in _context.VendorCompletionDocuments.AsNoTracking()
|
||||||
|
on link.DocumentId equals document.Id
|
||||||
|
where document.IsDeleted == null || document.IsDeleted == false
|
||||||
|
orderby link.DocumentId
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
link.UpliftRequestId,
|
||||||
|
document.Id,
|
||||||
|
document.OriginalFileName
|
||||||
|
}).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.GroupBy(row => row.UpliftRequestId)
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => (IReadOnlyList<UpliftEvidenceFileData>)group
|
||||||
|
.Select(row => new UpliftEvidenceFileData
|
||||||
|
{
|
||||||
|
Id = row.Id,
|
||||||
|
Name = row.OriginalFileName ?? ""
|
||||||
|
})
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList<int> documentIds)
|
||||||
|
{
|
||||||
|
foreach (var documentId in documentIds)
|
||||||
|
{
|
||||||
|
_context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument
|
||||||
|
{
|
||||||
|
UpliftRequest = request,
|
||||||
|
DocumentId = documentId
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken)
|
public async Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
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.
|
||||||
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(
|
||||||
|
int upliftRequestId,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int? documentId = null);
|
||||||
|
Task<IReadOnlyDictionary<int, IReadOnlyList<UpliftEvidenceFileData>>> GetEvidenceFilesAsync(
|
||||||
|
IReadOnlyCollection<int> upliftRequestIds,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList<int> documentIds);
|
||||||
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
// SH-393: the tracked dispatch a work-order uplift is written to, resolved through the
|
// SH-393: the tracked dispatch a work-order uplift is written to, resolved through the
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,8 @@ public sealed class VendorDocumentsOptions
|
||||||
public const string SectionName = "VendorDocuments";
|
public const string SectionName = "VendorDocuments";
|
||||||
|
|
||||||
public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024;
|
public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// why: local Development has no ClamAV host, so a required scan would leave every
|
||||||
|
// uplift file Pending. Production leaves this false and keeps the file quarantined.
|
||||||
|
public bool PassWhenScannerUnavailable { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ namespace SeaHaven.Services.DTOs
|
||||||
public DateTime? DecidedAt { get; set; }
|
public DateTime? DecidedAt { get; set; }
|
||||||
public string DecidedByName { get; set; } = "";
|
public string DecidedByName { get; set; } = "";
|
||||||
public string DecisionNote { get; set; } = "";
|
public string DecisionNote { get; set; } = "";
|
||||||
|
public int? EvidenceDocumentId { get; set; }
|
||||||
|
public string EvidenceFileName { get; set; } = "";
|
||||||
|
public List<WorkOrderUpliftAttachmentDto> Attachments { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderUpliftAttachmentDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WorkOrderUpliftListDto
|
public class WorkOrderUpliftListDto
|
||||||
|
|
@ -23,6 +32,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? EvidenceDocumentId { get; set; }
|
||||||
|
public List<int>? EvidenceDocumentIds { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RevokeWorkOrderUpliftRequestDto
|
public class RevokeWorkOrderUpliftRequestDto
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
DecidedAt = row.DecidedAt,
|
DecidedAt = row.DecidedAt,
|
||||||
DecidedByName = decidedByName,
|
DecidedByName = decidedByName,
|
||||||
DecisionNote = row.DecisionNote ?? "",
|
DecisionNote = row.DecisionNote ?? "",
|
||||||
|
EvidenceDocumentId = row.EvidenceDocumentId,
|
||||||
|
EvidenceFileName = row.EvidenceFileName ?? "",
|
||||||
|
Attachments = row.Attachments
|
||||||
|
.Select(file => new WorkOrderUpliftAttachmentDto { Id = file.Id, Name = file.Name })
|
||||||
|
.ToList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +72,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
DecidedAt = request.DecidedAt,
|
DecidedAt = request.DecidedAt,
|
||||||
DecidedByName = decidedByName ?? "",
|
DecidedByName = decidedByName ?? "",
|
||||||
DecisionNote = request.DecisionNote ?? "",
|
DecisionNote = request.DecisionNote ?? "",
|
||||||
|
EvidenceDocumentId = request.EvidenceDocumentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static WorkOrderUpliftDto MapRevokedItem(
|
public static WorkOrderUpliftDto MapRevokedItem(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderUpliftEvidenceAssociation
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<int> Normalize(int? single, IEnumerable<int>? many)
|
||||||
|
{
|
||||||
|
var ids = new List<int>();
|
||||||
|
if (single is int one && one > 0)
|
||||||
|
ids.Add(one);
|
||||||
|
|
||||||
|
if (many == null)
|
||||||
|
return ids;
|
||||||
|
|
||||||
|
foreach (var id in many)
|
||||||
|
{
|
||||||
|
if (id > 0 && !ids.Contains(id))
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task RequirePassedAsync(
|
||||||
|
IVendorDocumentDataService documents,
|
||||||
|
IReadOnlyList<int> documentIds,
|
||||||
|
int dispatchId,
|
||||||
|
int vendorId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var documentId in documentIds)
|
||||||
|
{
|
||||||
|
var evidence = await documents.GetUpliftEvidenceAsync(
|
||||||
|
documentId,
|
||||||
|
dispatchId,
|
||||||
|
vendorId,
|
||||||
|
cancellationToken);
|
||||||
|
if (evidence == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The selected evidence document is not available or has not passed scanning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
|
@ -17,6 +18,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ApprovalsOptions _approvalsOptions;
|
private readonly ApprovalsOptions _approvalsOptions;
|
||||||
private readonly IWorkOrderUpliftService? _workOrderUpliftService;
|
private readonly IWorkOrderUpliftService? _workOrderUpliftService;
|
||||||
|
private readonly IWorkOrderAccountResolver? _accountResolver;
|
||||||
|
private readonly IWorkOrderDetailDataService? _detailData;
|
||||||
|
|
||||||
public UpliftService(
|
public UpliftService(
|
||||||
IUpliftDataService upliftData,
|
IUpliftDataService upliftData,
|
||||||
|
|
@ -24,7 +27,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
IVendorDocumentStoragePort documentStorage,
|
IVendorDocumentStoragePort documentStorage,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IOptions<ApprovalsOptions> approvalsOptions,
|
IOptions<ApprovalsOptions> approvalsOptions,
|
||||||
IWorkOrderUpliftService? workOrderUpliftService = null)
|
IWorkOrderUpliftService? workOrderUpliftService = null,
|
||||||
|
IWorkOrderAccountResolver? accountResolver = null,
|
||||||
|
IWorkOrderDetailDataService? detailData = null)
|
||||||
{
|
{
|
||||||
_upliftData = upliftData;
|
_upliftData = upliftData;
|
||||||
_dispatchData = dispatchData;
|
_dispatchData = dispatchData;
|
||||||
|
|
@ -32,6 +37,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_approvalsOptions = approvalsOptions.Value;
|
_approvalsOptions = approvalsOptions.Value;
|
||||||
_workOrderUpliftService = workOrderUpliftService;
|
_workOrderUpliftService = workOrderUpliftService;
|
||||||
|
_accountResolver = accountResolver;
|
||||||
|
_detailData = detailData;
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -330,9 +337,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
// service resolves the document by the request's own linkage (no client-supplied
|
// service resolves the document by the request's own linkage (no client-supplied
|
||||||
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
||||||
// completion documents resolve to NotFound. A scan that has not Passed is Locked.
|
// completion documents resolve to NotFound. A scan that has not Passed is Locked.
|
||||||
public async Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
|
public async Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
int id,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int? documentId = null)
|
||||||
{
|
{
|
||||||
var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken);
|
var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken, documentId);
|
||||||
if (evidence == null
|
if (evidence == null
|
||||||
|| evidence.EvidenceDocumentId == null
|
|| evidence.EvidenceDocumentId == null
|
||||||
|| !string.Equals(evidence.Purpose, VendorDocumentPurpose.UpliftEvidence, StringComparison.Ordinal))
|
|| !string.Equals(evidence.Purpose, VendorDocumentPurpose.UpliftEvidence, StringComparison.Ordinal))
|
||||||
|
|
@ -340,9 +351,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
return UpliftEvidenceDownloadResultDTO.NotFound();
|
return UpliftEvidenceDownloadResultDTO.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!UserCanApprove(user, evidence.RequiredTier))
|
if (!await CanReadWorkOrderEvidenceAsync(user, id, cancellationToken))
|
||||||
{
|
{
|
||||||
throw new UpliftForbiddenException($"Evidence access requires a Tier {evidence.RequiredTier} role");
|
throw new UpliftForbiddenException("You are not authorized to view evidence for this uplift request");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(evidence.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(evidence.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|
@ -356,6 +367,31 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
// Admin passes every permission check regardless of stored configuration,
|
// Admin passes every permission check regardless of stored configuration,
|
||||||
// so the tier-role lists only govern non-Admin approvers.
|
// so the tier-role lists only govern non-Admin approvers.
|
||||||
|
private async Task<bool> CanReadWorkOrderEvidenceAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
int upliftRequestId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_accountResolver == null || _detailData == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int? accountFilter;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
accountFilter = _accountResolver.ResolveAccountFilter(user);
|
||||||
|
}
|
||||||
|
catch (WorkOrderBoardValidationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftRequestId, cancellationToken);
|
||||||
|
if (workOrderId == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return await _detailData.ExistsAsync(workOrderId.Value, cancellationToken, accountFilter);
|
||||||
|
}
|
||||||
|
|
||||||
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
||||||
{
|
{
|
||||||
if (user.IsInRole("Admin")) return true;
|
if (user.IsInRole("Admin")) return true;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderUpliftEvidenceService : IWorkOrderUpliftEvidenceService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"application/pdf", "image/jpeg", "image/jpg", "image/png"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
|
private readonly IDispatchDataService _dispatchData;
|
||||||
|
private readonly IVendorDocumentDataService _documentData;
|
||||||
|
private readonly IVendorDocumentStoragePort _documentStorage;
|
||||||
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||||
|
private readonly VendorDocumentsOptions _documentOptions;
|
||||||
|
|
||||||
|
public WorkOrderUpliftEvidenceService(
|
||||||
|
IWorkOrderDetailDataService detailData,
|
||||||
|
IDispatchDataService dispatchData,
|
||||||
|
IVendorDocumentDataService documentData,
|
||||||
|
IVendorDocumentStoragePort documentStorage,
|
||||||
|
IWorkOrderAccountResolver accountResolver,
|
||||||
|
IOptions<VendorDocumentsOptions> documentOptions)
|
||||||
|
{
|
||||||
|
_detailData = detailData;
|
||||||
|
_dispatchData = dispatchData;
|
||||||
|
_documentData = documentData;
|
||||||
|
_documentStorage = documentStorage;
|
||||||
|
_accountResolver = accountResolver;
|
||||||
|
_documentOptions = documentOptions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UploadCompletionDocumentResultDTO?> UploadAsync(
|
||||||
|
int workOrderId,
|
||||||
|
IFormFile file,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken);
|
||||||
|
if (target == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken);
|
||||||
|
var latest = await _documentData.GetLatestForDispatchAsync(target.Dispatch.Id, cancellationToken);
|
||||||
|
var version = (latest?.Version ?? 0) + 1;
|
||||||
|
var storedFileName = $"{target.Dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}";
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable;
|
||||||
|
var document = new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
VendorId = target.Dispatch.VendorId,
|
||||||
|
DispatchId = target.Dispatch.Id,
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
OriginalFileName = Path.GetFileName(file.FileName),
|
||||||
|
StoredFileName = storedFileName,
|
||||||
|
ContentType = contentType,
|
||||||
|
SizeBytes = bytes.Length,
|
||||||
|
ScanStatus = passWithoutScanner ? "Passed" : "Pending",
|
||||||
|
ReviewStatus = "Processing",
|
||||||
|
ScannedAt = passWithoutScanner ? now : null,
|
||||||
|
Version = version,
|
||||||
|
Purpose = VendorDocumentPurpose.UpliftEvidence,
|
||||||
|
CreatedDate = now
|
||||||
|
};
|
||||||
|
|
||||||
|
await _documentData.AddAsync(document, cancellationToken);
|
||||||
|
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
DispatchId = target.Dispatch.Id,
|
||||||
|
UserId = user.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||||
|
FieldName = $"Dispatch {target.Dispatch.DispatchNumber} Uplift Evidence",
|
||||||
|
NewValue = document.OriginalFileName,
|
||||||
|
Action = "uplift_evidence_uploaded",
|
||||||
|
ActorType = "internal",
|
||||||
|
CreatedAt = now
|
||||||
|
}, cancellationToken);
|
||||||
|
await _documentData.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
using var stored = new MemoryStream(bytes);
|
||||||
|
await _documentStorage.SaveAsync(
|
||||||
|
target.Dispatch.VendorId,
|
||||||
|
target.Dispatch.Id,
|
||||||
|
storedFileName,
|
||||||
|
stored,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new UploadCompletionDocumentResultDTO
|
||||||
|
{
|
||||||
|
Id = document.Id,
|
||||||
|
Version = document.Version,
|
||||||
|
ScanStatus = document.ScanStatus,
|
||||||
|
ReviewStatus = document.ReviewStatus,
|
||||||
|
Purpose = document.Purpose
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VendorDocumentStatusDTO?> GetStatusAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int documentId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken);
|
||||||
|
if (target == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var document = await _documentData.GetMetadataForVendorDispatchAsync(
|
||||||
|
documentId,
|
||||||
|
target.Dispatch.Id,
|
||||||
|
target.Dispatch.VendorId,
|
||||||
|
cancellationToken);
|
||||||
|
if (document == null || document.Purpose != VendorDocumentPurpose.UpliftEvidence)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new VendorDocumentStatusDTO
|
||||||
|
{
|
||||||
|
Id = document.Id,
|
||||||
|
OriginalFileName = document.OriginalFileName,
|
||||||
|
ScanStatus = document.ScanStatus,
|
||||||
|
ReviewStatus = document.ReviewStatus,
|
||||||
|
Purpose = document.Purpose
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OpenDispatch?> ResolveOpenDispatchAsync(
|
||||||
|
int workOrderId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accountFilter = _accountResolver.ResolveAccountFilter(user);
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
|
||||||
|
workOrderId,
|
||||||
|
cancellationToken,
|
||||||
|
accountFilter);
|
||||||
|
if (workOrder?.PrimaryDispatchId is not int dispatchId)
|
||||||
|
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
||||||
|
|
||||||
|
if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot attach uplift evidence on a '{workOrder.LifecycleStatus}' work order");
|
||||||
|
}
|
||||||
|
|
||||||
|
var dispatch = await _dispatchData.GetByIdAsync(dispatchId);
|
||||||
|
if (dispatch == null)
|
||||||
|
throw new KeyNotFoundException("Dispatch not found");
|
||||||
|
|
||||||
|
if (dispatch.Status is "Verified" or "Cancelled" or "Canceled" or "Refused")
|
||||||
|
throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch");
|
||||||
|
|
||||||
|
return new OpenDispatch(dispatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync(
|
||||||
|
IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
throw new InvalidOperationException("An evidence file is required.");
|
||||||
|
|
||||||
|
if (file.Length > _documentOptions.MaxSizeBytes)
|
||||||
|
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
|
||||||
|
|
||||||
|
var contentType = (file.ContentType ?? string.Empty).Trim();
|
||||||
|
if (!AllowedContentTypes.Contains(contentType))
|
||||||
|
throw new InvalidOperationException("Only PDF, JPG, and PNG documents are accepted.");
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await file.CopyToAsync(buffer, cancellationToken);
|
||||||
|
var bytes = buffer.ToArray();
|
||||||
|
if (!MatchesSignature(contentType, bytes))
|
||||||
|
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
|
||||||
|
|
||||||
|
return (contentType, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesSignature(string contentType, byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return bytes.Length >= 4
|
||||||
|
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return bytes.Length >= 8
|
||||||
|
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
|
||||||
|
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeExtension(string fileName)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(fileName);
|
||||||
|
return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record OpenDispatch(Dispatch Dispatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly ITeamPermissionPolicy _permissionPolicy;
|
private readonly ITeamPermissionPolicy _permissionPolicy;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ApprovalsOptions _approvalsOptions;
|
private readonly ApprovalsOptions _approvalsOptions;
|
||||||
|
private readonly IVendorDocumentDataService _documentData;
|
||||||
|
|
||||||
public WorkOrderUpliftService(
|
public WorkOrderUpliftService(
|
||||||
IUpliftDataService upliftData,
|
IUpliftDataService upliftData,
|
||||||
|
|
@ -35,7 +36,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
ITeamPermissionOverrideDataService permissionOverrideData,
|
ITeamPermissionOverrideDataService permissionOverrideData,
|
||||||
ITeamPermissionPolicy permissionPolicy,
|
ITeamPermissionPolicy permissionPolicy,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IOptions<ApprovalsOptions> approvalsOptions)
|
IOptions<ApprovalsOptions> approvalsOptions,
|
||||||
|
IVendorDocumentDataService documentData)
|
||||||
{
|
{
|
||||||
_upliftData = upliftData;
|
_upliftData = upliftData;
|
||||||
_dispatchData = dispatchData;
|
_dispatchData = dispatchData;
|
||||||
|
|
@ -46,6 +48,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
_permissionPolicy = permissionPolicy;
|
_permissionPolicy = permissionPolicy;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_approvalsOptions = approvalsOptions.Value;
|
_approvalsOptions = approvalsOptions.Value;
|
||||||
|
_documentData = documentData;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
|
public async Task<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
|
||||||
|
|
@ -57,6 +60,15 @@ namespace SeaHaven.Services.Implementation
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var rows = await _upliftData.GetForWorkOrderAsync(workOrderId, cancellationToken);
|
var rows = await _upliftData.GetForWorkOrderAsync(workOrderId, cancellationToken);
|
||||||
|
var files = await _upliftData.GetEvidenceFilesAsync(
|
||||||
|
rows.Select(row => row.Id).ToArray(),
|
||||||
|
cancellationToken);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (files.TryGetValue(row.Id, out var attachments))
|
||||||
|
row.Attachments = attachments.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
return rows.Select(WorkOrderUpliftContractMapper.MapItem).ToList();
|
return rows.Select(WorkOrderUpliftContractMapper.MapItem).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +99,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
workOrderId,
|
workOrderId,
|
||||||
request.Amount,
|
request.Amount,
|
||||||
notes,
|
notes,
|
||||||
|
WorkOrderUpliftEvidenceAssociation.Normalize(
|
||||||
|
request.EvidenceDocumentId,
|
||||||
|
request.EvidenceDocumentIds),
|
||||||
userId,
|
userId,
|
||||||
requesterName,
|
requesterName,
|
||||||
accountFilter,
|
accountFilter,
|
||||||
|
|
@ -128,6 +143,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
decimal amount,
|
decimal amount,
|
||||||
string notes,
|
string notes,
|
||||||
|
IReadOnlyList<int> evidenceDocumentIds,
|
||||||
string? userId,
|
string? userId,
|
||||||
string requesterName,
|
string requesterName,
|
||||||
int? accountFilter,
|
int? accountFilter,
|
||||||
|
|
@ -168,6 +184,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
|| await _upliftData.HasActiveAsync(dispatch.Id, cancellationToken))
|
|| await _upliftData.HasActiveAsync(dispatch.Id, cancellationToken))
|
||||||
throw new InvalidOperationException("An open uplift request already exists for this work order");
|
throw new InvalidOperationException("An open uplift request already exists for this work order");
|
||||||
|
|
||||||
|
await WorkOrderUpliftEvidenceAssociation.RequirePassedAsync(
|
||||||
|
_documentData,
|
||||||
|
evidenceDocumentIds,
|
||||||
|
dispatch.Id,
|
||||||
|
dispatch.VendorId,
|
||||||
|
cancellationToken);
|
||||||
|
var evidenceDocumentId = evidenceDocumentIds.Count == 0 ? (int?)null : evidenceDocumentIds[0];
|
||||||
|
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
var current = dispatch.NTEAmount ?? 0m;
|
var current = dispatch.NTEAmount ?? 0m;
|
||||||
var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken);
|
var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken);
|
||||||
|
|
@ -185,6 +209,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
current,
|
current,
|
||||||
amount,
|
amount,
|
||||||
notes,
|
notes,
|
||||||
|
evidenceDocumentId,
|
||||||
|
evidenceDocumentIds,
|
||||||
UpliftStatus.NoApprovalRequired,
|
UpliftStatus.NoApprovalRequired,
|
||||||
requiredTier: 0,
|
requiredTier: 0,
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
|
|
@ -201,8 +227,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
requesterName,
|
requesterName,
|
||||||
current,
|
current,
|
||||||
amount,
|
amount,
|
||||||
notes,
|
notes,
|
||||||
UpliftStatus.Pending,
|
evidenceDocumentId,
|
||||||
|
evidenceDocumentIds,
|
||||||
|
UpliftStatus.Pending,
|
||||||
requiredTier: 1,
|
requiredTier: 1,
|
||||||
now + _approvalsOptions.EffectiveExpiration,
|
now + _approvalsOptions.EffectiveExpiration,
|
||||||
UpliftNotificationStatus.Pending,
|
UpliftNotificationStatus.Pending,
|
||||||
|
|
@ -370,6 +398,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
decimal currentNte,
|
decimal currentNte,
|
||||||
decimal amount,
|
decimal amount,
|
||||||
string notes,
|
string notes,
|
||||||
|
int? evidenceDocumentId,
|
||||||
|
IReadOnlyList<int> evidenceDocumentIds,
|
||||||
string status,
|
string status,
|
||||||
int requiredTier,
|
int requiredTier,
|
||||||
DateTime? expiresAt,
|
DateTime? expiresAt,
|
||||||
|
|
@ -391,6 +421,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
createdby = userId,
|
createdby = userId,
|
||||||
ExpiresAt = expiresAt,
|
ExpiresAt = expiresAt,
|
||||||
NotificationStatus = notificationStatus,
|
NotificationStatus = notificationStatus,
|
||||||
|
EvidenceDocumentId = evidenceDocumentId,
|
||||||
};
|
};
|
||||||
if (status == UpliftStatus.NoApprovalRequired)
|
if (status == UpliftStatus.NoApprovalRequired)
|
||||||
{
|
{
|
||||||
|
|
@ -398,6 +429,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
dispatch.LastModificationTime = now;
|
dispatch.LastModificationTime = now;
|
||||||
}
|
}
|
||||||
await _upliftData.StageAsync(created, cancellationToken);
|
await _upliftData.StageAsync(created, cancellationToken);
|
||||||
|
_upliftData.StageEvidenceDocuments(created, evidenceDocumentIds);
|
||||||
await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken);
|
await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken);
|
||||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||||
return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null);
|
return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ namespace SeaHaven.Services.Interfaces
|
||||||
// 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.
|
||||||
Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
|
Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
int id,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int? documentId = null);
|
||||||
bool CanApprove(ClaimsPrincipal user, int tier);
|
bool CanApprove(ClaimsPrincipal user, int tier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderUpliftEvidenceService
|
||||||
|
{
|
||||||
|
Task<UploadCompletionDocumentResultDTO?> UploadAsync(
|
||||||
|
int workOrderId,
|
||||||
|
IFormFile file,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<VendorDocumentStatusDTO?> GetStatusAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int documentId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,8 @@ public sealed class UpliftAmountPerCreationPathTests
|
||||||
new TeamPermissionOverrideDataService(context),
|
new TeamPermissionOverrideDataService(context),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(NewOptions()));
|
Options.Create(NewOptions()),
|
||||||
|
new VendorDocumentDataService(context));
|
||||||
|
|
||||||
private static UpliftService NewQueueService(ApplicationDbContext context) =>
|
private static UpliftService NewQueueService(ApplicationDbContext context) =>
|
||||||
new(new UpliftDataService(context),
|
new(new UpliftDataService(context),
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ internal static class WorkOrderAccountTestHelpers
|
||||||
new TeamPermissionOverrideDataService(context),
|
new TeamPermissionOverrideDataService(context),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Microsoft.Extensions.Options.Options.Create(new SeaHaven.Services.Configuration.ApprovalsOptions()));
|
Microsoft.Extensions.Options.Options.Create(new SeaHaven.Services.Configuration.ApprovalsOptions()),
|
||||||
|
new VendorDocumentDataService(context));
|
||||||
|
|
||||||
public static ClaimsPrincipal AccountUser(
|
public static ClaimsPrincipal AccountUser(
|
||||||
string userId = "actor-1",
|
string userId = "actor-1",
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new TeamPermissionOverrideDataService(context),
|
new TeamPermissionOverrideDataService(context),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()),
|
||||||
|
new VendorDocumentDataService(context));
|
||||||
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
|
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
|
||||||
|
|
||||||
var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
|
var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
|
||||||
|
|
@ -270,7 +271,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new TeamPermissionOverrideDataService(context),
|
new TeamPermissionOverrideDataService(context),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()),
|
||||||
|
new VendorDocumentDataService(context));
|
||||||
var cancel = new WorkOrderBoardCancelService(
|
var cancel = new WorkOrderBoardCancelService(
|
||||||
new ThrowingSaveMutationData(mutationData),
|
new ThrowingSaveMutationData(mutationData),
|
||||||
boardService,
|
boardService,
|
||||||
|
|
@ -347,7 +349,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new TeamPermissionOverrideDataService(createContext),
|
new TeamPermissionOverrideDataService(createContext),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()),
|
||||||
|
new VendorDocumentDataService(createContext));
|
||||||
var boardData = new WorkOrderBoardDataService(cancelContext);
|
var boardData = new WorkOrderBoardDataService(cancelContext);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(cancelContext);
|
var mutationData = new WorkOrderBoardMutationDataService(cancelContext);
|
||||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext));
|
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext));
|
||||||
|
|
@ -362,7 +365,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new TeamPermissionOverrideDataService(cancelContext),
|
new TeamPermissionOverrideDataService(cancelContext),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()),
|
||||||
|
new VendorDocumentDataService(cancelContext));
|
||||||
var cancel = new WorkOrderBoardCancelService(
|
var cancel = new WorkOrderBoardCancelService(
|
||||||
mutationData,
|
mutationData,
|
||||||
boardService,
|
boardService,
|
||||||
|
|
@ -454,7 +458,9 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
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<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, int? documentId = null) => throw new NotSupportedException();
|
||||||
|
public Task<IReadOnlyDictionary<int, IReadOnlyList<UpliftEvidenceFileData>>> GetEvidenceFilesAsync(IReadOnlyCollection<int> upliftRequestIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
public void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList<int> documentIds) => 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();
|
||||||
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,8 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
|
||||||
UpliftTier1MaxUsd = 2500m,
|
UpliftTier1MaxUsd = 2500m,
|
||||||
Tier1Roles = new[] { "Approver" },
|
Tier1Roles = new[] { "Approver" },
|
||||||
Tier2Roles = new[] { "Manager" },
|
Tier2Roles = new[] { "Manager" },
|
||||||
}));
|
}),
|
||||||
|
new VendorDocumentDataService(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<WorkOrderBoardRowDto> BoardCreateWithVendorAsync(
|
private static Task<WorkOrderBoardRowDto> BoardCreateWithVendorAsync(
|
||||||
|
|
|
||||||
219
SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs
Normal file
219
SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class WorkOrderUpliftEvidenceServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<WorkOrder> SeedWorkOrderAsync(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "dispatcher-1",
|
||||||
|
UserName = "dispatcher-1",
|
||||||
|
FirstName = "Alex",
|
||||||
|
LastName = "Dispatcher",
|
||||||
|
});
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
context.Dispatches.Add(new Dispatch
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
VendorId = 1,
|
||||||
|
WorkOrderId = 1,
|
||||||
|
NTEAmount = 1000m,
|
||||||
|
DispatchNumber = "DIS-10",
|
||||||
|
Status = "Scheduled",
|
||||||
|
});
|
||||||
|
var workOrder = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
PrimaryDispatchId = 10,
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
};
|
||||||
|
context.workOrders.Add(workOrder);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
return workOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (WorkOrderUpliftEvidenceService Service, MemoryStorage Storage) NewService(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
bool passWhenScannerUnavailable = false)
|
||||||
|
{
|
||||||
|
var storage = new MemoryStorage();
|
||||||
|
var service = new WorkOrderUpliftEvidenceService(
|
||||||
|
new WorkOrderDetailDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
new VendorDocumentDataService(context),
|
||||||
|
storage,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context),
|
||||||
|
Options.Create(new VendorDocumentsOptions
|
||||||
|
{
|
||||||
|
PassWhenScannerUnavailable = passWhenScannerUnavailable,
|
||||||
|
}));
|
||||||
|
return (service, storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal Dispatcher()
|
||||||
|
=> WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1");
|
||||||
|
|
||||||
|
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
|
||||||
|
{
|
||||||
|
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadAsync_StoresPendingUpliftEvidenceForThePrimaryDispatch()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var workOrder = await SeedWorkOrderAsync(context);
|
||||||
|
var (service, storage) = NewService(context);
|
||||||
|
var pdf = "%PDF-1.4"u8.ToArray();
|
||||||
|
|
||||||
|
var uploaded = await service.UploadAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
FormFile(pdf, "quote.pdf", "application/pdf"),
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var document = Assert.Single(context.VendorCompletionDocuments);
|
||||||
|
Assert.Equal(document.Id, uploaded!.Id);
|
||||||
|
Assert.Equal("Pending", uploaded.ScanStatus);
|
||||||
|
Assert.Null(document.ScannedAt);
|
||||||
|
Assert.Equal(VendorDocumentPurpose.UpliftEvidence, document.Purpose);
|
||||||
|
Assert.Equal(1, document.VendorId);
|
||||||
|
Assert.Equal(10, document.DispatchId);
|
||||||
|
Assert.Equal("quote.pdf", document.OriginalFileName);
|
||||||
|
Assert.Null(document.ReplacesDocumentId);
|
||||||
|
var saved = Assert.Single(storage.Saved);
|
||||||
|
Assert.Equal((1, 10), (saved.VendorId, saved.DispatchId));
|
||||||
|
var audit = Assert.Single(context.WorkOrderAuditLogs);
|
||||||
|
Assert.Equal("uplift_evidence_uploaded", audit.Action);
|
||||||
|
Assert.Equal("internal", audit.ActorType);
|
||||||
|
Assert.Equal("dispatcher-1", audit.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadAsync_PassWhenScannerUnavailable_StoresPassedEvidence()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var workOrder = await SeedWorkOrderAsync(context);
|
||||||
|
var (service, _) = NewService(context, passWhenScannerUnavailable: true);
|
||||||
|
|
||||||
|
var uploaded = await service.UploadAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var document = Assert.Single(context.VendorCompletionDocuments);
|
||||||
|
Assert.Equal("Passed", uploaded!.ScanStatus);
|
||||||
|
Assert.Equal("Passed", document.ScanStatus);
|
||||||
|
Assert.NotNull(document.ScannedAt);
|
||||||
|
Assert.Equal("Processing", document.ReviewStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadAsync_MismatchedSignature_SavesNothing()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var workOrder = await SeedWorkOrderAsync(context);
|
||||||
|
var (service, storage) = NewService(context);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.UploadAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
FormFile("not a pdf"u8.ToArray(), "quote.pdf", "application/pdf"),
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Contains("signature", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(context.VendorCompletionDocuments);
|
||||||
|
Assert.Empty(storage.Saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_ReturnsScanStatusOnlyForUpliftEvidenceOnTheDispatch()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var workOrder = await SeedWorkOrderAsync(context);
|
||||||
|
var (service, _) = NewService(context);
|
||||||
|
var uploaded = await service.UploadAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var pending = await service.GetStatusAsync(workOrder.Id, uploaded!.Id, Dispatcher(), CancellationToken.None);
|
||||||
|
Assert.Equal("Pending", pending!.ScanStatus);
|
||||||
|
Assert.Equal("quote.pdf", pending.OriginalFileName);
|
||||||
|
|
||||||
|
var stored = Assert.Single(context.VendorCompletionDocuments);
|
||||||
|
stored.ScanStatus = "Passed";
|
||||||
|
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
Id = 90,
|
||||||
|
VendorId = 1,
|
||||||
|
DispatchId = 10,
|
||||||
|
WorkOrderId = workOrder.Id,
|
||||||
|
OriginalFileName = "done.pdf",
|
||||||
|
StoredFileName = "done.pdf",
|
||||||
|
ContentType = "application/pdf",
|
||||||
|
Purpose = "Completion",
|
||||||
|
ScanStatus = "Passed",
|
||||||
|
Version = 1,
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var passed = await service.GetStatusAsync(workOrder.Id, uploaded.Id, Dispatcher(), CancellationToken.None);
|
||||||
|
Assert.Equal("Passed", passed!.ScanStatus);
|
||||||
|
Assert.Null(await service.GetStatusAsync(workOrder.Id, 90, Dispatcher(), CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MemoryStorage : IVendorDocumentStoragePort
|
||||||
|
{
|
||||||
|
public List<(int VendorId, int DispatchId, string Name)> Saved { get; } = new();
|
||||||
|
|
||||||
|
public async Task SaveAsync(
|
||||||
|
int vendorId,
|
||||||
|
int dispatchId,
|
||||||
|
string storedFileName,
|
||||||
|
Stream content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await content.CopyToAsync(buffer, cancellationToken);
|
||||||
|
Saved.Add((vendorId, dispatchId, storedFileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public void Delete(int vendorId, int dispatchId, string storedFileName)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,8 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
new TeamPermissionOverrideDataService(context),
|
new TeamPermissionOverrideDataService(context),
|
||||||
new TeamPermissionPolicy(),
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(NewOptions()));
|
Options.Create(NewOptions()),
|
||||||
|
new VendorDocumentDataService(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
||||||
|
|
@ -138,7 +139,9 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
|
|
||||||
Assert.NotNull(created);
|
Assert.NotNull(created);
|
||||||
Assert.Equal("auto_approved", created!.Status);
|
Assert.Equal("auto_approved", created!.Status);
|
||||||
|
Assert.Null(created.EvidenceDocumentId);
|
||||||
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
||||||
|
Assert.Null(Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -843,4 +846,132 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None));
|
service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None));
|
||||||
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_PassedEvidenceOnSameDispatch_LinksDocument()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||||
|
var document = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewService(context);
|
||||||
|
|
||||||
|
var created = await service.CreateAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
new CreateWorkOrderUpliftRequestDto
|
||||||
|
{
|
||||||
|
Amount = 400m,
|
||||||
|
Notes = "Quote",
|
||||||
|
EvidenceDocumentId = document.Id,
|
||||||
|
},
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(document.Id, created!.EvidenceDocumentId);
|
||||||
|
Assert.Equal(document.Id, Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("other-dispatch")]
|
||||||
|
[InlineData("wrong-purpose")]
|
||||||
|
[InlineData("pending-scan")]
|
||||||
|
public async Task CreateAsync_EvidenceThatDoesNotQualify_RejectsWithoutSaving(string defect)
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||||
|
var document = defect switch
|
||||||
|
{
|
||||||
|
"other-dispatch" => SeedEvidence(context, dispatchId: 99, dispatch.VendorId, workOrder.Id),
|
||||||
|
"wrong-purpose" => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, purpose: "Completion"),
|
||||||
|
_ => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, scanStatus: "Pending"),
|
||||||
|
};
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewService(context);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.CreateAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
new CreateWorkOrderUpliftRequestDto
|
||||||
|
{
|
||||||
|
Amount = 400m,
|
||||||
|
Notes = "Quote",
|
||||||
|
EvidenceDocumentId = document.Id,
|
||||||
|
},
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Contains("not available", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_MultipleEvidenceDocuments_LinksEachPassedFile()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||||
|
var quote = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id);
|
||||||
|
quote.OriginalFileName = "quote.pdf";
|
||||||
|
var photo = new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
Id = 23,
|
||||||
|
VendorId = dispatch.VendorId,
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
WorkOrderId = workOrder.Id,
|
||||||
|
OriginalFileName = "photo.jpg",
|
||||||
|
StoredFileName = "stored.jpg",
|
||||||
|
ContentType = "image/jpeg",
|
||||||
|
SizeBytes = 4,
|
||||||
|
ScanStatus = "Passed",
|
||||||
|
ReviewStatus = "Processing",
|
||||||
|
Purpose = "UpliftEvidence",
|
||||||
|
Version = 1,
|
||||||
|
};
|
||||||
|
context.VendorCompletionDocuments.Add(photo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewService(context);
|
||||||
|
|
||||||
|
var created = await service.CreateAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
new CreateWorkOrderUpliftRequestDto
|
||||||
|
{
|
||||||
|
Amount = 400m,
|
||||||
|
Notes = "Quote and photo",
|
||||||
|
EvidenceDocumentIds = new List<int> { quote.Id, photo.Id },
|
||||||
|
},
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(quote.Id, created!.EvidenceDocumentId);
|
||||||
|
Assert.Equal(2, context.DispatchUpliftRequestDocuments.Count());
|
||||||
|
var listed = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None);
|
||||||
|
var names = Assert.Single(listed!).Attachments.Select(file => file.Name).OrderBy(name => name).ToArray();
|
||||||
|
Assert.Equal(new[] { "photo.jpg", "quote.pdf" }, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorCompletionDocument SeedEvidence(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int dispatchId,
|
||||||
|
int vendorId,
|
||||||
|
int workOrderId,
|
||||||
|
string scanStatus = "Passed",
|
||||||
|
string purpose = "UpliftEvidence")
|
||||||
|
{
|
||||||
|
var document = new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
Id = 22,
|
||||||
|
VendorId = vendorId,
|
||||||
|
DispatchId = dispatchId,
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
OriginalFileName = "quote.pdf",
|
||||||
|
StoredFileName = "stored.pdf",
|
||||||
|
ContentType = "application/pdf",
|
||||||
|
SizeBytes = 4,
|
||||||
|
ScanStatus = scanStatus,
|
||||||
|
ReviewStatus = "Processing",
|
||||||
|
Purpose = purpose,
|
||||||
|
Version = 1,
|
||||||
|
};
|
||||||
|
context.VendorCompletionDocuments.Add(document);
|
||||||
|
return document;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue