From e02f9774dc7322f3b07ed4b2aa2ecf5eb9a87bd7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:51:48 -0300 Subject: [PATCH] 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. --- .../UpliftWorkflowTests.cs | 86 +++++++++++++++++++ .../Models/VendorPortalReadModels.cs | 3 + .../Implementation/UpliftDataService.cs | 3 + .../DTOs/VendorPortalServiceDTOs.cs | 2 + .../Implementation/VendorPortalService.cs | 5 +- 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 87f07cf..0c2f27d 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -496,6 +496,92 @@ public sealed class UpliftWorkflowTests await act.Should().ThrowAsync(); } + // 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 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(); + 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() { diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 84a00e8..689f3f0 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -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; } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index cbcf76d..09566f3 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -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, diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index afdaf18..8e4be7c 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -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; } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 047abd0..6bb22ab 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -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)) {