mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
fix(work-orders): accept a scanned uplift evidence file (SH-388)
Dispatchers can attach one evidence file, and create links it only after that document has passed scanning.
This commit is contained in:
parent
eb9b7eb50e
commit
31d4352a23
14 changed files with 674 additions and 12 deletions
|
|
@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
|
|||
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
||||
/// 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
|
||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
|
||||
/// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 54 routes
|
||||
/// come from 52 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
@ -66,6 +66,7 @@ public class WorkOrderRouteContractTests
|
|||
"GET {id:int}/comments",
|
||||
"GET {id:int}/detail",
|
||||
"GET {id:int}/uplifts",
|
||||
"GET {id:int}/uplift-evidence/{documentId:int}",
|
||||
"GET {id:int}/media",
|
||||
"GET {id:int}/media/{mediaId:int}/content",
|
||||
"PATCH {id:int}/board",
|
||||
|
|
@ -91,6 +92,7 @@ public class WorkOrderRouteContractTests
|
|||
"POST {id:int}/completion-doc",
|
||||
"POST {id:int}/media",
|
||||
"POST {id:int}/uplifts",
|
||||
"POST {id:int}/uplift-evidence",
|
||||
"POST {id:int}/uplifts/{upliftId:int}/cancel",
|
||||
"POST {id:int}/uplifts/{upliftId:int}/revoke",
|
||||
"PUT completion-templates/{id:int}",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public sealed class WorkOrderUpliftControllerTests
|
|||
Mock.Of<IWorkOrderDetailService>(),
|
||||
Mock.Of<IWorkOrderCommentService>(),
|
||||
upliftService.Object,
|
||||
Mock.Of<IWorkOrderUpliftEvidenceService>(),
|
||||
Mock.Of<ILogger<WorkOrderDetailController>>());
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "dispatcher-1") };
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
|
|
|
|||
|
|
@ -20,17 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly IWorkOrderDetailService _workOrderDetailService;
|
||||
private readonly IWorkOrderCommentService _workOrderCommentService;
|
||||
private readonly IWorkOrderUpliftService _workOrderUpliftService;
|
||||
private readonly IWorkOrderUpliftEvidenceService _workOrderUpliftEvidenceService;
|
||||
private readonly ILogger<WorkOrderDetailController> _logger;
|
||||
|
||||
public WorkOrderDetailController(
|
||||
IWorkOrderDetailService workOrderDetailService,
|
||||
IWorkOrderCommentService workOrderCommentService,
|
||||
IWorkOrderUpliftService workOrderUpliftService,
|
||||
IWorkOrderUpliftEvidenceService workOrderUpliftEvidenceService,
|
||||
ILogger<WorkOrderDetailController> logger)
|
||||
{
|
||||
_workOrderDetailService = workOrderDetailService;
|
||||
_workOrderCommentService = workOrderCommentService;
|
||||
_workOrderUpliftService = workOrderUpliftService;
|
||||
_workOrderUpliftEvidenceService = workOrderUpliftEvidenceService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +181,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")]
|
||||
public async Task<IActionResult> CancelUplift(int id, int upliftId, CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using Api.SeaHavenIndustries.Observability;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using Sentry;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
|
|
@ -11,17 +13,20 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<VendorDocumentScanWorker> _logger;
|
||||
private readonly IHub _sentryHub;
|
||||
private readonly bool _passWhenScannerUnavailable;
|
||||
|
||||
public VendorDocumentScanWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<VendorDocumentScanWorker> logger,
|
||||
IHub sentryHub)
|
||||
IHub sentryHub,
|
||||
IOptions<VendorDocumentsOptions> documentOptions)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
_sentryHub = sentryHub;
|
||||
_passWhenScannerUnavailable = documentOptions.Value.PassWhenScannerUnavailable;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
|
|
@ -54,14 +59,16 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
{
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
||||
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
|
||||
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
|
||||
var passed = result == DocumentScanResult.Passed
|
||||
|| (result == DocumentScanResult.Unavailable && _passWhenScannerUnavailable);
|
||||
document.ScanStatus = passed ? "Passed" : "Rejected";
|
||||
document.ReviewStatus = passed ? "Processing" : "Rejected";
|
||||
document.RejectionReason = result == DocumentScanResult.Infected
|
||||
? "The upload failed malware scanning."
|
||||
: document.RejectionReason;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
"VendorDocuments": {
|
||||
"PassWhenScannerUnavailable": true
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JWT": {
|
||||
"ValidAudience": "http://localhost:4200",
|
||||
|
|
|
|||
|
|
@ -47,4 +47,8 @@ public sealed class VendorDocumentsOptions
|
|||
public const string SectionName = "VendorDocuments";
|
||||
|
||||
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,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? DecidedAt { get; set; }
|
||||
public string DecidedByName { get; set; } = "";
|
||||
public string DecisionNote { get; set; } = "";
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string EvidenceFileName { get; set; } = "";
|
||||
}
|
||||
|
||||
public class WorkOrderUpliftListDto
|
||||
|
|
@ -23,6 +25,7 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public decimal Amount { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
}
|
||||
|
||||
public class RevokeWorkOrderUpliftRequestDto
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ namespace SeaHaven.Services.Helpers
|
|||
DecidedAt = row.DecidedAt,
|
||||
DecidedByName = decidedByName,
|
||||
DecisionNote = row.DecisionNote ?? "",
|
||||
EvidenceDocumentId = row.EvidenceDocumentId,
|
||||
EvidenceFileName = row.EvidenceFileName ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +68,7 @@ namespace SeaHaven.Services.Helpers
|
|||
DecidedAt = request.DecidedAt,
|
||||
DecidedByName = decidedByName ?? "",
|
||||
DecisionNote = request.DecisionNote ?? "",
|
||||
EvidenceDocumentId = request.EvidenceDocumentId,
|
||||
};
|
||||
|
||||
public static WorkOrderUpliftDto MapRevokedItem(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IUserDataService _userData;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
private readonly IVendorDocumentDataService _documentData;
|
||||
|
||||
public WorkOrderUpliftService(
|
||||
IUpliftDataService upliftData,
|
||||
|
|
@ -29,7 +30,8 @@ namespace SeaHaven.Services.Implementation
|
|||
IWorkOrderAccountResolver accountResolver,
|
||||
IUserDataService userData,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ApprovalsOptions> approvalsOptions)
|
||||
IOptions<ApprovalsOptions> approvalsOptions,
|
||||
IVendorDocumentDataService documentData)
|
||||
{
|
||||
_upliftData = upliftData;
|
||||
_dispatchData = dispatchData;
|
||||
|
|
@ -38,6 +40,7 @@ namespace SeaHaven.Services.Implementation
|
|||
_userData = userData;
|
||||
_timeProvider = timeProvider;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
_documentData = documentData;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
|
||||
|
|
@ -75,6 +78,7 @@ namespace SeaHaven.Services.Implementation
|
|||
workOrderId,
|
||||
request.Amount,
|
||||
notes,
|
||||
request.EvidenceDocumentId,
|
||||
userId,
|
||||
requesterName,
|
||||
accountFilter,
|
||||
|
|
@ -86,6 +90,7 @@ namespace SeaHaven.Services.Implementation
|
|||
int workOrderId,
|
||||
decimal amount,
|
||||
string notes,
|
||||
int? evidenceDocumentId,
|
||||
string? userId,
|
||||
string requesterName,
|
||||
int? accountFilter,
|
||||
|
|
@ -114,6 +119,8 @@ namespace SeaHaven.Services.Implementation
|
|||
if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken))
|
||||
throw new InvalidOperationException("An open uplift request already exists for this work order");
|
||||
|
||||
await RequirePassedEvidenceAsync(evidenceDocumentId, dispatch.Id, dispatch.VendorId, cancellationToken);
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var current = dispatch.NTEAmount ?? 0m;
|
||||
var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken);
|
||||
|
|
@ -131,6 +138,7 @@ namespace SeaHaven.Services.Implementation
|
|||
current,
|
||||
amount,
|
||||
notes,
|
||||
evidenceDocumentId,
|
||||
UpliftStatus.NoApprovalRequired,
|
||||
requiredTier: 0,
|
||||
expiresAt: null,
|
||||
|
|
@ -148,6 +156,7 @@ namespace SeaHaven.Services.Implementation
|
|||
current,
|
||||
amount,
|
||||
notes,
|
||||
evidenceDocumentId,
|
||||
UpliftStatus.Pending,
|
||||
requiredTier: 1,
|
||||
now + _approvalsOptions.EffectiveExpiration,
|
||||
|
|
@ -337,6 +346,7 @@ namespace SeaHaven.Services.Implementation
|
|||
decimal currentNte,
|
||||
decimal amount,
|
||||
string notes,
|
||||
int? evidenceDocumentId,
|
||||
string status,
|
||||
int requiredTier,
|
||||
DateTime? expiresAt,
|
||||
|
|
@ -358,6 +368,7 @@ namespace SeaHaven.Services.Implementation
|
|||
createdby = userId,
|
||||
ExpiresAt = expiresAt,
|
||||
NotificationStatus = notificationStatus,
|
||||
EvidenceDocumentId = evidenceDocumentId,
|
||||
};
|
||||
if (status == UpliftStatus.NoApprovalRequired)
|
||||
{
|
||||
|
|
@ -399,6 +410,27 @@ namespace SeaHaven.Services.Implementation
|
|||
return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId);
|
||||
}
|
||||
|
||||
private async Task RequirePassedEvidenceAsync(
|
||||
int? evidenceDocumentId,
|
||||
int dispatchId,
|
||||
int vendorId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!evidenceDocumentId.HasValue)
|
||||
return;
|
||||
|
||||
var evidence = await _documentData.GetUpliftEvidenceAsync(
|
||||
evidenceDocumentId.Value,
|
||||
dispatchId,
|
||||
vendorId,
|
||||
cancellationToken);
|
||||
if (evidence == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The selected evidence document is not available or has not passed scanning");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -204,7 +204,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new UserDataService(context),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
Options.Create(new ApprovalsOptions()),
|
||||
new VendorDocumentDataService(context));
|
||||
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
|
||||
|
||||
var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
|
||||
|
|
@ -265,7 +266,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new UserDataService(context),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
Options.Create(new ApprovalsOptions()),
|
||||
new VendorDocumentDataService(context));
|
||||
var cancel = new WorkOrderBoardCancelService(
|
||||
new ThrowingSaveMutationData(mutationData),
|
||||
boardService,
|
||||
|
|
@ -333,7 +335,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
WorkOrderAccountTestHelpers.Resolver(createContext),
|
||||
new UserDataService(createContext),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
Options.Create(new ApprovalsOptions()),
|
||||
new VendorDocumentDataService(createContext));
|
||||
var boardData = new WorkOrderBoardDataService(cancelContext);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(cancelContext);
|
||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext));
|
||||
|
|
@ -346,7 +349,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
WorkOrderAccountTestHelpers.Resolver(cancelContext),
|
||||
new UserDataService(cancelContext),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
Options.Create(new ApprovalsOptions()),
|
||||
new VendorDocumentDataService(cancelContext));
|
||||
var cancel = new WorkOrderBoardCancelService(
|
||||
mutationData,
|
||||
boardService,
|
||||
|
|
|
|||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,8 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new UserDataService(context),
|
||||
TimeProvider.System,
|
||||
Options.Create(NewOptions()));
|
||||
Options.Create(NewOptions()),
|
||||
new VendorDocumentDataService(context));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
||||
|
|
@ -120,7 +121,9 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("auto_approved", created!.Status);
|
||||
Assert.Null(created.EvidenceDocumentId);
|
||||
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
||||
Assert.Null(Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -564,4 +567,88 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None));
|
||||
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);
|
||||
}
|
||||
|
||||
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