Keep work-order uplift requests read-only in the Vendor Portal

A vendor could withdraw (or cancel) an uplift a dispatcher raised from the work
order. Withdraw and its cancel alias now refuse requests with createdby set,
using the portal's not-found response, and the portal read model reports
RaisedByVendor so the portal can hide Revise and Withdraw on those requests.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 02:51:48 -03:00
parent 3b69b9fbcc
commit e02f9774dc
5 changed files with 98 additions and 1 deletions

View file

@ -496,6 +496,92 @@ public sealed class UpliftWorkflowTests
await act.Should().ThrowAsync<InvalidOperationException>();
}
// Raised from the work order by an internal user: createdby is set and RequestedNTE
// holds the increase. Vendor sessions have no identity user, so they never set it.
private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new()
{
DispatchId = dispatchId,
CurrentNTE = 600m,
RequestedNTE = 90m,
Status = status,
RequiredTier = 1,
VendorReason = "Extra parts",
RequestedByVendorName = "Alex Dispatcher",
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = new DateTime(2026, 3, 1),
};
[Theory]
[InlineData("Pending", "withdraw")]
[InlineData("ChangesRequested", "withdraw")]
[InlineData("Pending", "cancel")]
[InlineData("ChangesRequested", "cancel")]
public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route)
{
using var context = NewContext();
var (_, _, dispatch) = await SeedAsync(context, nte: 600m);
var workOrderRequest = WorkOrderRequest(dispatch.Id, status);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
Func<Task> act = route == "cancel"
? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None)
: () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
after.Status.Should().Be(status);
after.DecidedAt.Should().BeNull();
after.RequestedNTE.Should().Be(90m);
after.createdby.Should().Be("dispatcher-1");
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel");
}
[Fact]
public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested;
await context.SaveChangesAsync();
var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Withdrawn);
context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn);
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested);
}
[Fact]
public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None);
var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.UpliftRequests.Should().HaveCount(2);
detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue();
detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse();
}
[Fact]
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
{

View file

@ -45,6 +45,9 @@ namespace Data.SeaHavenIndustries
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
// True when the vendor raised the request in the portal; false when it was raised
// from the work order. Only vendor-raised requests are revisable or withdrawable.
public bool RaisedByVendor { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }

View file

@ -243,6 +243,9 @@ namespace SeaHaven.DataServices.Implementation
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
// Vendor sessions have no identity user, so createdby is null
// only on requests the vendor raised in the portal.
RaisedByVendor = u.createdby == null,
CreatedDate = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,

View file

@ -54,6 +54,8 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
// False for requests raised from the work order: the portal shows them read-only.
public bool RaisedByVendor { get; set; }
public DateTime? RequestedAt { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }

View file

@ -107,6 +107,7 @@ namespace SeaHaven.Services.Implementation
Status = UpliftStatus.ToCanonical(u.Status),
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
RaisedByVendor = u.RaisedByVendor,
RequestedAt = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
@ -694,7 +695,9 @@ namespace SeaHaven.Services.Implementation
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
if (req == null) throw new KeyNotFoundException("Uplift request not found");
// A vendor withdraws only requests it raised; requests raised from the work order
// (createdby set) are read-only in the portal.
if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found");
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
{