fix(uplifts): resolve evidence without a primary dispatch

Upload and status use the same dispatch create already selects, so a work
order with no primary can still attach evidence. Status no longer rejects a
terminal work order or dispatch, and create, cancel, and revoke return the
stored evidence file name and attachments.

Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-10-01 14:55:07 +00:00
parent 2f4b783615
commit b11b66538a
No known key found for this signature in database
5 changed files with 352 additions and 35 deletions

View file

@ -6,6 +6,10 @@ using SeaHaven.Services.Implementation;
namespace SeaHaven.Services.Helpers
{
public sealed record StoredUpliftEvidence(
string? FileName,
IReadOnlyList<UpliftEvidenceFileData> Attachments);
public static class WorkOrderUpliftContractMapper
{
public static WorkOrderUpliftSummaryDto MapSummary(
@ -53,13 +57,15 @@ namespace SeaHaven.Services.Helpers
DecisionNote = row.DecisionNote ?? "",
EvidenceDocumentId = row.EvidenceDocumentId,
EvidenceFileName = row.EvidenceFileName ?? "",
Attachments = row.Attachments
.Select(file => new WorkOrderUpliftAttachmentDto { Id = file.Id, Name = file.Name })
.ToList(),
Attachments = MapAttachments(row.Attachments),
};
}
public static WorkOrderUpliftDto MapItem(DispatchUpliftRequest request, string? requestedByName, string? decidedByName)
public static WorkOrderUpliftDto MapItem(
DispatchUpliftRequest request,
string? requestedByName,
string? decidedByName,
StoredUpliftEvidence evidence)
=> new()
{
Id = request.Id,
@ -73,18 +79,27 @@ namespace SeaHaven.Services.Helpers
DecidedByName = decidedByName ?? "",
DecisionNote = request.DecisionNote ?? "",
EvidenceDocumentId = request.EvidenceDocumentId,
EvidenceFileName = evidence.FileName ?? "",
Attachments = MapAttachments(evidence.Attachments),
};
public static WorkOrderUpliftDto MapRevokedItem(
DispatchUpliftRequest request,
string? requestedByName,
string decidedByName)
string decidedByName,
StoredUpliftEvidence evidence)
{
var dto = MapItem(request, requestedByName, decidedByName);
var dto = MapItem(request, requestedByName, decidedByName, evidence);
dto.Status = "revoked";
return dto;
}
private static List<WorkOrderUpliftAttachmentDto> MapAttachments(
IEnumerable<UpliftEvidenceFileData> files)
=> files
.Select(file => new WorkOrderUpliftAttachmentDto { Id = file.Id, Name = file.Name })
.ToList();
public static string ToFrontendStatus(string? backendStatus)
{
return UpliftStatus.ToCanonical(backendStatus) switch

View file

@ -18,6 +18,7 @@ namespace SeaHaven.Services.Implementation
};
private readonly IWorkOrderDetailDataService _detailData;
private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData;
private readonly IVendorDocumentDataService _documentData;
private readonly IVendorDocumentStoragePort _documentStorage;
@ -26,6 +27,7 @@ namespace SeaHaven.Services.Implementation
public WorkOrderUpliftEvidenceService(
IWorkOrderDetailDataService detailData,
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IVendorDocumentDataService documentData,
IVendorDocumentStoragePort documentStorage,
@ -33,6 +35,7 @@ namespace SeaHaven.Services.Implementation
IOptions<VendorDocumentsOptions> documentOptions)
{
_detailData = detailData;
_upliftData = upliftData;
_dispatchData = dispatchData;
_documentData = documentData;
_documentStorage = documentStorage;
@ -46,20 +49,29 @@ namespace SeaHaven.Services.Implementation
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken);
if (target == null)
var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken);
if (workOrder == null)
return null;
var dispatch = await ResolveUpliftDispatchAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
RejectTerminalTarget(workOrder, dispatch);
var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken);
var latest = await _documentData.GetLatestForDispatchAsync(target.Dispatch.Id, cancellationToken);
var latest = await _documentData.GetLatestForDispatchAsync(dispatch.Id, cancellationToken);
var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{target.Dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}";
var storedFileName = $"{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,
VendorId = dispatch.VendorId,
DispatchId = dispatch.Id,
WorkOrderId = workOrderId,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = storedFileName,
@ -77,9 +89,9 @@ namespace SeaHaven.Services.Implementation
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
DispatchId = target.Dispatch.Id,
DispatchId = dispatch.Id,
UserId = user.FindFirstValue(ClaimTypes.NameIdentifier),
FieldName = $"Dispatch {target.Dispatch.DispatchNumber} Uplift Evidence",
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift Evidence",
NewValue = document.OriginalFileName,
Action = "uplift_evidence_uploaded",
ActorType = "internal",
@ -89,8 +101,8 @@ namespace SeaHaven.Services.Implementation
using var stored = new MemoryStream(bytes);
await _documentStorage.SaveAsync(
target.Dispatch.VendorId,
target.Dispatch.Id,
dispatch.VendorId,
dispatch.Id,
storedFileName,
stored,
cancellationToken);
@ -111,14 +123,23 @@ namespace SeaHaven.Services.Implementation
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken);
if (target == null)
// A poll reads the document on the dispatch create would pick. It does not
// require that work order or dispatch to still accept a new upload.
var workOrder = await FindAccessibleWorkOrderAsync(workOrderId, user, cancellationToken);
if (workOrder == null)
return null;
var dispatch = await ResolveUpliftDispatchAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
return null;
var document = await _documentData.GetMetadataForVendorDispatchAsync(
documentId,
target.Dispatch.Id,
target.Dispatch.VendorId,
dispatch.Id,
dispatch.VendorId,
cancellationToken);
if (document == null || document.Purpose != VendorDocumentPurpose.UpliftEvidence)
return null;
@ -133,7 +154,7 @@ namespace SeaHaven.Services.Implementation
};
}
private async Task<OpenDispatch?> ResolveOpenDispatchAsync(
private async Task<WorkOrder?> FindAccessibleWorkOrderAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
@ -142,27 +163,31 @@ namespace SeaHaven.Services.Implementation
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter))
return null;
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
return 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");
}
private Task<Dispatch?> ResolveUpliftDispatchAsync(
int workOrderId,
int? primaryDispatchId,
CancellationToken cancellationToken)
=> _upliftData.GetUpliftDispatchForWorkOrderAsync(
workOrderId,
primaryDispatchId,
cancellationToken);
private static void RejectTerminalTarget(WorkOrder workOrder, Dispatch dispatch)
{
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(
@ -220,7 +245,5 @@ namespace SeaHaven.Services.Implementation
var extension = Path.GetExtension(fileName);
return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension;
}
private sealed record OpenDispatch(Dispatch Dispatch);
}
}

View file

@ -284,7 +284,12 @@ namespace SeaHaven.Services.Implementation
await _upliftData.SaveChangesAsync(cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(req, requesterName, await ResolveUserDisplayNameAsync(userId, cancellationToken));
return await MapStoredRequestAsync(
req,
dispatch,
requesterName,
await ResolveUserDisplayNameAsync(userId, cancellationToken),
cancellationToken);
}
public async Task<WorkOrderUpliftDto?> RevokeAsync(
@ -375,7 +380,7 @@ namespace SeaHaven.Services.Implementation
var decidedByName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName);
return await MapStoredRequestAsync(req, dispatch, requesterName, decidedByName, cancellationToken);
}
public async Task WithdrawPendingForWorkOrderAsync(
@ -432,7 +437,54 @@ namespace SeaHaven.Services.Implementation
_upliftData.StageEvidenceDocuments(created, evidenceDocumentIds);
await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null);
return await MapStoredRequestAsync(created, dispatch, requesterName, null, cancellationToken);
}
private async Task<WorkOrderUpliftDto> MapStoredRequestAsync(
DispatchUpliftRequest request,
Dispatch dispatch,
string? requestedByName,
string? decidedByName,
CancellationToken cancellationToken)
{
var evidence = await LoadStoredEvidenceAsync(request, dispatch, cancellationToken);
if (request.Status == UpliftStatus.Revoked)
{
return WorkOrderUpliftContractMapper.MapRevokedItem(
request,
requestedByName,
decidedByName ?? "",
evidence);
}
return WorkOrderUpliftContractMapper.MapItem(
request,
requestedByName,
decidedByName,
evidence);
}
private async Task<StoredUpliftEvidence> LoadStoredEvidenceAsync(
DispatchUpliftRequest request,
Dispatch dispatch,
CancellationToken cancellationToken)
{
var files = await _upliftData.GetEvidenceFilesAsync(new[] { request.Id }, cancellationToken);
if (!files.TryGetValue(request.Id, out var attachments))
attachments = Array.Empty<UpliftEvidenceFileData>();
string? fileName = null;
if (request.EvidenceDocumentId is int documentId)
{
var document = await _documentData.GetMetadataForVendorDispatchAsync(
documentId,
dispatch.Id,
dispatch.VendorId,
cancellationToken);
fileName = document?.OriginalFileName;
}
return new StoredUpliftEvidence(fileName, attachments);
}
/// <summary>

View file

@ -2,10 +2,12 @@ using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -62,6 +64,7 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
var storage = new MemoryStorage();
var service = new WorkOrderUpliftEvidenceService(
new WorkOrderDetailDataService(context),
new UpliftDataService(context),
new DispatchDataService(context),
new VendorDocumentDataService(context),
storage,
@ -186,11 +189,156 @@ public sealed class WorkOrderUpliftEvidenceServiceTests
ScanStatus = "Passed",
Version = 1,
});
context.Dispatches.Add(new Dispatch
{
Id = 11,
VendorId = 1,
WorkOrderId = workOrder.Id,
DispatchNumber = "DIS-11",
Status = "Scheduled",
});
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
Id = 91,
VendorId = 1,
DispatchId = 11,
WorkOrderId = workOrder.Id,
OriginalFileName = "other.pdf",
StoredFileName = "other.pdf",
ContentType = "application/pdf",
Purpose = VendorDocumentPurpose.UpliftEvidence,
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));
Assert.Null(await service.GetStatusAsync(workOrder.Id, 91, Dispatcher(), CancellationToken.None));
}
[Theory]
[InlineData(LifecycleStatus.Completed, "Scheduled")]
[InlineData(LifecycleStatus.Canceled, "Scheduled")]
[InlineData(LifecycleStatus.Scheduled, "Verified")]
[InlineData(LifecycleStatus.Scheduled, "Cancelled")]
[InlineData(LifecycleStatus.Scheduled, "Canceled")]
[InlineData(LifecycleStatus.Scheduled, "Refused")]
public async Task GetStatusAsync_ReturnsUpliftEvidenceWhenTargetIsTerminal(
LifecycleStatus lifecycle,
string dispatchStatus)
{
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);
workOrder.LifecycleStatus = lifecycle;
context.Dispatches.Single().Status = dispatchStatus;
await context.SaveChangesAsync();
var status = await service.GetStatusAsync(workOrder.Id, uploaded!.Id, Dispatcher(), CancellationToken.None);
Assert.Equal("Pending", status!.ScanStatus);
Assert.Equal("quote.pdf", status.OriginalFileName);
}
[Theory]
[InlineData(LifecycleStatus.Completed, "Scheduled", "Completed")]
[InlineData(LifecycleStatus.Scheduled, "Verified", "Verified")]
public async Task UploadAsync_TerminalWorkOrderOrDispatch_Rejects(
LifecycleStatus lifecycle,
string dispatchStatus,
string expected)
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
workOrder.LifecycleStatus = lifecycle;
context.Dispatches.Single().Status = dispatchStatus;
await context.SaveChangesAsync();
var (service, storage) = NewService(context);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.UploadAsync(
workOrder.Id,
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None));
Assert.Contains(expected, ex.Message, StringComparison.Ordinal);
Assert.Empty(context.VendorCompletionDocuments);
Assert.Empty(storage.Saved);
}
[Fact]
public async Task UploadThenCreate_WithoutPrimaryDispatch_PersistsUpliftOnTheSameDispatch()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
workOrder.PrimaryDispatchId = null;
var role = new IdentityRole("Admin");
context.Roles.Add(role);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = role.Id,
});
await context.SaveChangesAsync();
var (evidence, _) = NewService(context);
var uploaded = await evidence.UploadAsync(
workOrder.Id,
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None);
var pending = await evidence.GetStatusAsync(
workOrder.Id,
uploaded!.Id,
Dispatcher(),
CancellationToken.None);
Assert.Equal("Pending", pending!.ScanStatus);
var document = Assert.Single(context.VendorCompletionDocuments);
document.ScanStatus = "Passed";
await context.SaveChangesAsync();
var created = await NewUpliftService(context).CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto
{
Amount = 400m,
Notes = "Quote",
EvidenceDocumentId = uploaded.Id,
},
Dispatcher(),
CancellationToken.None);
var request = Assert.Single(context.DispatchUpliftRequests);
Assert.NotNull(created);
Assert.Equal(uploaded.Id, request.EvidenceDocumentId);
Assert.Equal(document.DispatchId, request.DispatchId);
Assert.Equal(10, request.DispatchId);
}
private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context)
{
return new WorkOrderUpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions { UpliftTier1MaxUsd = 2500m }),
new VendorDocumentDataService(context));
}
private sealed class MemoryStorage : IVendorDocumentStoragePort

View file

@ -515,6 +515,43 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal("cancelled", cancelled!.Status);
}
[Fact]
public async Task CancelAsync_ReturnsStoredEvidenceFileNameAndAttachments()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
var document = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 600m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
createdby = "dispatcher-1",
EvidenceDocumentId = document.Id,
});
context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument
{
UpliftRequestId = 100,
DocumentId = document.Id,
});
await context.SaveChangesAsync();
var cancelled = await NewService(context).CancelAsync(
workOrder.Id,
100,
Dispatcher(),
CancellationToken.None);
Assert.Equal("cancelled", cancelled!.Status);
Assert.Equal("quote.pdf", cancelled.EvidenceFileName);
var attachment = Assert.Single(cancelled.Attachments);
Assert.Equal(document.Id, attachment.Id);
Assert.Equal("quote.pdf", attachment.Name);
}
[Fact]
public async Task RevokeAsync_ApprovedRequiresAdminReason()
{
@ -761,6 +798,40 @@ public sealed class WorkOrderUpliftServiceTests
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
}
[Fact]
public async Task RevokeAsync_ReturnsStoredEvidenceFileNameAndAttachments()
{
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);
var revoked = await service.RevokeAsync(
workOrder.Id,
created!.Id,
new RevokeWorkOrderUpliftRequestDto(),
DispatcherRoleOnly(),
CancellationToken.None);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal("quote.pdf", revoked.EvidenceFileName);
var attachment = Assert.Single(revoked.Attachments);
Assert.Equal(document.Id, attachment.Id);
Assert.Equal("quote.pdf", attachment.Name);
}
[Fact]
public async Task RevokeAsync_AdminOwnerRevokingAutoApproved_IsForbiddenAndKeepsAllowanceConsumed()
{
@ -868,6 +939,10 @@ public sealed class WorkOrderUpliftServiceTests
CancellationToken.None);
Assert.Equal(document.Id, created!.EvidenceDocumentId);
Assert.Equal("quote.pdf", created.EvidenceFileName);
var attachment = Assert.Single(created.Attachments);
Assert.Equal(document.Id, attachment.Id);
Assert.Equal("quote.pdf", attachment.Name);
Assert.Equal(document.Id, Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId);
}
@ -942,6 +1017,10 @@ public sealed class WorkOrderUpliftServiceTests
CancellationToken.None);
Assert.Equal(quote.Id, created!.EvidenceDocumentId);
Assert.Equal("quote.pdf", created.EvidenceFileName);
Assert.Equal(
new[] { "quote.pdf", "photo.jpg" },
created.Attachments.Select(file => file.Name).ToArray());
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();