mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
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:
parent
3b69b9fbcc
commit
e02f9774dc
5 changed files with 98 additions and 1 deletions
|
|
@ -496,6 +496,92 @@ public sealed class UpliftWorkflowTests
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
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]
|
[Fact]
|
||||||
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
|
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; set; }
|
||||||
public string? RequestedByVendorName { 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? CreatedDate { get; set; }
|
||||||
public DateTime? DecidedAt { get; set; }
|
public DateTime? DecidedAt { get; set; }
|
||||||
public string? DecisionNote { get; set; }
|
public string? DecisionNote { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
RequestedByVendorName = u.RequestedByVendorName,
|
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,
|
CreatedDate = u.CreatedDate,
|
||||||
DecidedAt = u.DecidedAt,
|
DecidedAt = u.DecidedAt,
|
||||||
DecisionNote = u.DecisionNote,
|
DecisionNote = u.DecisionNote,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; set; }
|
||||||
public string? RequestedByVendorName { 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? RequestedAt { get; set; }
|
||||||
public DateTime? DecidedAt { get; set; }
|
public DateTime? DecidedAt { get; set; }
|
||||||
public string? DecisionNote { get; set; }
|
public string? DecisionNote { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
Status = UpliftStatus.ToCanonical(u.Status),
|
Status = UpliftStatus.ToCanonical(u.Status),
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
RequestedByVendorName = u.RequestedByVendorName,
|
RequestedByVendorName = u.RequestedByVendorName,
|
||||||
|
RaisedByVendor = u.RaisedByVendor,
|
||||||
RequestedAt = u.CreatedDate,
|
RequestedAt = u.CreatedDate,
|
||||||
DecidedAt = u.DecidedAt,
|
DecidedAt = u.DecidedAt,
|
||||||
DecisionNote = u.DecisionNote,
|
DecisionNote = u.DecisionNote,
|
||||||
|
|
@ -694,7 +695,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||||
|
|
||||||
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
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))
|
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue