feat(work-orders): WO-scoped uplift endpoints and board summary (SH-196)

Expose workorders/{id}/uplifts list/create/cancel/revoke for the SH-196 dialog, aggregate upliftSummary on board rows, and add service/controller regression tests.
This commit is contained in:
arthur.bassi 2026-08-13 14:49:56 -03:00
parent 5f614fe893
commit b81cfbb005
17 changed files with 1034 additions and 6 deletions

View file

@ -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. 47 routes
/// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes).
/// duplication is collapsed here, so this is the distinct action-relative contract. 51 routes
/// come from 49 actions (Editworkorder and GetWorkorderById each bind two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
@ -65,6 +65,7 @@ public class WorkOrderRouteContractTests
"GET {id:int}/audit",
"GET {id:int}/comments",
"GET {id:int}/detail",
"GET {id:int}/uplifts",
"GET {id:int}/media",
"PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}",
@ -88,6 +89,9 @@ public class WorkOrderRouteContractTests
"POST {id:int}/comments",
"POST {id:int}/completion-doc",
"POST {id:int}/media",
"POST {id:int}/uplifts",
"POST {id:int}/uplifts/{upliftId:int}/cancel",
"POST {id:int}/uplifts/{upliftId:int}/revoke",
"PUT completion-templates/{id:int}",
"PUT {id:int}",
};

View file

@ -0,0 +1,115 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class WorkOrderUpliftControllerTests
{
private static WorkOrderDetailController NewController(
Mock<IWorkOrderUpliftService> upliftService,
params string[] roles)
{
var controller = new WorkOrderDetailController(
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
upliftService.Object,
Mock.Of<ILogger<WorkOrderDetailController>>());
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "dispatcher-1") };
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"))
}
};
return controller;
}
[Fact]
public async Task ListUplifts_ReturnsItemsEnvelope()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.ListAsync(7, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<WorkOrderUpliftDto>
{
new() { Id = 11, Status = "pending", Amount = 500m, Notes = "Need parts" }
});
var controller = NewController(service);
var result = await controller.ListUplifts(7, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var list = Assert.IsType<WorkOrderUpliftListDto>(envelope.Data);
Assert.Single(list.Items);
Assert.Equal(11, list.Items[0].Id);
}
[Fact]
public async Task CreateUplift_ReturnsCreatedItem()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderUpliftDto { Id = 12, Status = "pending", Amount = 750m });
var controller = NewController(service);
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" },
CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var created = Assert.IsType<WorkOrderUpliftDto>(envelope.Data);
Assert.Equal(12, created.Id);
}
[Fact]
public async Task CancelUplift_NotFound_Returns404()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CancelAsync(7, 99, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("Uplift request not found"));
var controller = NewController(service);
var result = await controller.CancelUplift(7, 99, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task RevokeUplift_Forbidden_Returns403()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.RevokeAsync(
7,
99,
It.IsAny<RevokeWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("Admin role is required"));
var controller = NewController(service, "Dispatcher");
var result = await controller.RevokeUplift(
7,
99,
new RevokeWorkOrderUpliftRequestDto { Reason = "Too high" },
CancellationToken.None);
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
}

View file

@ -1,7 +1,9 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
@ -17,13 +19,19 @@ namespace Api.SeaHavenIndustries.Controllers
{
private readonly IWorkOrderDetailService _workOrderDetailService;
private readonly IWorkOrderCommentService _workOrderCommentService;
private readonly IWorkOrderUpliftService _workOrderUpliftService;
private readonly ILogger<WorkOrderDetailController> _logger;
public WorkOrderDetailController(
IWorkOrderDetailService workOrderDetailService,
IWorkOrderCommentService workOrderCommentService)
IWorkOrderCommentService workOrderCommentService,
IWorkOrderUpliftService workOrderUpliftService,
ILogger<WorkOrderDetailController> logger)
{
_workOrderDetailService = workOrderDetailService;
_workOrderCommentService = workOrderCommentService;
_workOrderUpliftService = workOrderUpliftService;
_logger = logger;
}
[HttpGet("{id:int}/detail")]
@ -126,5 +134,105 @@ namespace Api.SeaHavenIndustries.Controllers
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpGet("{id:int}/uplifts")]
public async Task<IActionResult> ListUplifts(int id, CancellationToken cancellationToken)
{
try
{
var items = await _workOrderUpliftService.ListAsync(id, User, cancellationToken);
if (items == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(new DataResponse { Status = "Success", Data = new WorkOrderUpliftListDto { Items = items.ToList() } });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("{id:int}/uplifts")]
public async Task<IActionResult> CreateUplift(
int id,
[FromBody] CreateWorkOrderUpliftRequestDto request,
CancellationToken cancellationToken)
{
try
{
var created = await _workOrderUpliftService.CreateAsync(id, request, User, cancellationToken);
if (created == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(new DataResponse { Status = "Success", Data = created });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The uplift request could not be created") });
}
}
[HttpPost("{id:int}/uplifts/{upliftId:int}/cancel")]
public async Task<IActionResult> CancelUplift(int id, int upliftId, CancellationToken cancellationToken)
{
try
{
var cancelled = await _workOrderUpliftService.CancelAsync(id, upliftId, User, cancellationToken);
if (cancelled == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(new DataResponse { Status = "Success", Data = cancelled });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The uplift request cannot be cancelled in its current state") });
}
}
[HttpPost("{id:int}/uplifts/{upliftId:int}/revoke")]
public async Task<IActionResult> RevokeUplift(
int id,
int upliftId,
[FromBody] RevokeWorkOrderUpliftRequestDto? request,
CancellationToken cancellationToken)
{
try
{
var revoked = await _workOrderUpliftService.RevokeAsync(
id,
upliftId,
request ?? new RevokeWorkOrderUpliftRequestDto(),
User,
cancellationToken);
if (revoked == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(new DataResponse { Status = "Success", Data = revoked });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The uplift request cannot be revoked in its current state") });
}
}
}
}

View file

@ -99,6 +99,11 @@ namespace Data.SeaHavenIndustries
public string? NotificationError { get; set; }
}
public class UpliftForWorkOrderData : UpliftForDispatchData
{
public string? CreatedByUserId { get; set; }
}
public class UpliftForDispatchData
{
public int Id { get; set; }

View file

@ -81,7 +81,36 @@ namespace SeaHaven.DataServices.Helpers
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
HasUplift = context.DispatchUpliftRequests.Any(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
PrimaryUpliftStatus = context.DispatchUpliftRequests
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
.OrderByDescending(u => u.CreatedDate)
.Select(u => u.Status)
.FirstOrDefault(),
PrimaryUpliftAmount = context.DispatchUpliftRequests
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
.OrderByDescending(u => u.CreatedDate)
.Select(u => (decimal?)u.RequestedNTE)
.FirstOrDefault()
})
.ToListAsync();
@ -134,6 +163,9 @@ namespace SeaHaven.DataServices.Helpers
w.RowVersion,
w.DispatchRowVersion,
w.PendingUpliftCount,
w.HasUplift,
w.PrimaryUpliftStatus,
w.PrimaryUpliftAmount,
isUnscheduled);
}).ToList();
}

View file

@ -100,6 +100,64 @@ namespace SeaHaven.DataServices.Implementation
}).ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
return await (from u in _context.DispatchUpliftRequests
where (u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == workOrderId
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId))
join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs
from dec in decs.DefaultIfEmpty()
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
from ev in evidences.DefaultIfEmpty()
orderby u.CreatedDate descending
select new UpliftForWorkOrderData
{
Id = u.Id,
DispatchId = u.DispatchId,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
CreatedDate = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
DecidedByFirstName = dec != null ? dec.FirstName : null,
DecidedByLastName = dec != null ? dec.LastName : null,
EvidenceDocumentId = u.EvidenceDocumentId,
ExpiresAt = u.ExpiresAt,
NotificationStatus = u.NotificationStatus,
NotificationError = u.NotificationError,
EvidenceFileName = ev != null ? ev.OriginalFileName : null,
EvidenceContentType = ev != null ? ev.ContentType : null,
EvidenceSizeBytes = ev != null ? ev.SizeBytes : null,
EvidenceScanPassed = ev != null && ev.ScanStatus == "Passed",
CreatedByUserId = u.createdby
}).ToListAsync(cancellationToken);
}
public async Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(
int requestId,
int workOrderId,
CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
.FirstOrDefaultAsync(u =>
u.Id == requestId
&& (u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == workOrderId
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)),
cancellationToken);
}
public async Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return await (from u in _context.DispatchUpliftRequests

View file

@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);

View file

@ -53,6 +53,9 @@ namespace SeaHaven.DataServices.Interfaces
byte[]? RowVersion,
byte[]? DispatchRowVersion,
int PendingUpliftCount,
bool HasUplift,
string? PrimaryUpliftStatus,
decimal? PrimaryUpliftAmount,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(

View file

@ -21,6 +21,14 @@ namespace SeaHaven.Services.DTOs
public int Total { get; set; }
}
public class WorkOrderUpliftSummaryDto
{
public bool HasUplift { get; set; }
public int PendingCount { get; set; }
public string? PrimaryStatus { get; set; }
public decimal? Amount { get; set; }
}
public class WorkOrderBoardRowDto
{
public int Id { get; set; }
@ -67,6 +75,8 @@ namespace SeaHaven.Services.DTOs
/// Authoritative completion-gate signal for the board/search/detail row contract.
/// </summary>
public int PendingUpliftCount { get; set; }
/// <summary>SH-196: board/search uplift column contract.</summary>
public WorkOrderUpliftSummaryDto? UpliftSummary { get; set; }
}
public class WorkOrderBoardPatchRequestDto

View file

@ -0,0 +1,32 @@
namespace SeaHaven.Services.DTOs
{
public class WorkOrderUpliftDto
{
public int Id { get; set; }
public string Status { get; set; } = "pending";
public decimal Amount { get; set; }
public string Notes { get; set; } = "";
public DateTime? RequestedAt { get; set; }
public string? RequestedById { get; set; }
public string RequestedByName { get; set; } = "";
public DateTime? DecidedAt { get; set; }
public string DecidedByName { get; set; } = "";
public string DecisionNote { get; set; } = "";
}
public class WorkOrderUpliftListDto
{
public List<WorkOrderUpliftDto> Items { get; set; } = new();
}
public class CreateWorkOrderUpliftRequestDto
{
public decimal Amount { get; set; }
public string? Notes { get; set; }
}
public class RevokeWorkOrderUpliftRequestDto
{
public string? Reason { get; set; }
}
}

View file

@ -0,0 +1,100 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderUpliftContractMapper
{
public static WorkOrderUpliftSummaryDto MapSummary(
bool hasUplift,
int pendingCount,
string? primaryStatus,
decimal? amount)
{
var summary = new WorkOrderUpliftSummaryDto
{
HasUplift = hasUplift || pendingCount > 0,
PendingCount = pendingCount,
};
if (!string.IsNullOrWhiteSpace(primaryStatus))
{
summary.PrimaryStatus = ToFrontendStatus(primaryStatus);
}
if (amount.HasValue)
{
summary.Amount = amount.Value;
}
return summary;
}
public static WorkOrderUpliftSummaryDto MapSummary(WorkOrderBoardRawRow row)
=> MapSummary(row.HasUplift, row.PendingUpliftCount, row.PrimaryUpliftStatus, row.PrimaryUpliftAmount);
public static WorkOrderUpliftDto MapItem(UpliftForWorkOrderData row)
{
var decidedByName = FormatName(row.DecidedByFirstName, row.DecidedByLastName);
return new WorkOrderUpliftDto
{
Id = row.Id,
Status = ToFrontendStatus(row.Status),
Amount = row.RequestedNTE,
Notes = row.VendorReason ?? "",
RequestedAt = row.CreatedDate,
RequestedById = row.CreatedByUserId,
RequestedByName = row.RequestedByVendorName ?? "",
DecidedAt = row.DecidedAt,
DecidedByName = decidedByName,
DecisionNote = row.DecisionNote ?? "",
};
}
public static WorkOrderUpliftDto MapItem(DispatchUpliftRequest request, string? requestedByName, string? decidedByName)
=> new()
{
Id = request.Id,
Status = ToFrontendStatus(request.Status),
Amount = request.RequestedNTE,
Notes = request.VendorReason ?? "",
RequestedAt = request.CreatedDate,
RequestedById = request.createdby,
RequestedByName = requestedByName ?? request.RequestedByVendorName ?? "",
DecidedAt = request.DecidedAt,
DecidedByName = decidedByName ?? "",
DecisionNote = request.DecisionNote ?? "",
};
public static WorkOrderUpliftDto MapRevokedItem(
DispatchUpliftRequest request,
string? requestedByName,
string decidedByName)
{
var dto = MapItem(request, requestedByName, decidedByName);
dto.Status = "revoked";
return dto;
}
public static string ToFrontendStatus(string? backendStatus)
{
return UpliftStatus.ToCanonical(backendStatus) switch
{
UpliftStatus.Pending => "pending",
UpliftStatus.Approved => "approved",
UpliftStatus.NoApprovalRequired => "auto_approved",
UpliftStatus.Rejected => "rejected",
UpliftStatus.Withdrawn => "cancelled",
UpliftStatus.Revoked => "revoked",
UpliftStatus.ChangesRequested => "pending",
UpliftStatus.Expired => "cancelled",
_ => "pending",
};
}
private static string FormatName(string? firstName, string? lastName)
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
}
}

View file

@ -15,13 +15,14 @@ namespace SeaHaven.Services.Implementation
public const string Withdrawn = "Withdrawn";
public const string Expired = "Expired";
public const string NoApprovalRequired = "NoApprovalRequired";
public const string Revoked = "Revoked";
// Legacy aliases retained only for reads; never written by new code.
public const string LegacyDenied = "Denied";
public const string LegacyCancelled = "Cancelled";
private static readonly HashSet<string> TerminalStates =
new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired };
new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired, Revoked, NoApprovalRequired };
private static readonly HashSet<string> ActiveStates =
new(StringComparer.Ordinal) { Pending, ChangesRequested };

View file

@ -127,7 +127,8 @@ namespace SeaHaven.Services.Implementation
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,
PendingUpliftCount = row.PendingUpliftCount
PendingUpliftCount = row.PendingUpliftCount,
UpliftSummary = WorkOrderUpliftContractMapper.MapSummary(row)
};
}

View file

@ -0,0 +1,299 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderUpliftService : IWorkOrderUpliftService
{
private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IUserDataService _userData;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
public WorkOrderUpliftService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
}
public async Task<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var rows = await _upliftData.GetForWorkOrderAsync(workOrderId, cancellationToken);
return rows.Select(WorkOrderUpliftContractMapper.MapItem).ToList();
}
public async Task<WorkOrderUpliftDto?> CreateAsync(
int workOrderId,
CreateWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
workOrderId,
cancellationToken,
_accountResolver.ResolveAccountFilter(user));
if (workOrder?.PrimaryDispatchId is not int dispatchId)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
var dispatch = await _dispatchData.GetByIdAsync(dispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
if (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var requesterName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var current = dispatch.NTEAmount ?? 0m;
var notes = request.Notes?.Trim() ?? "";
if (request.Amount <= current)
{
var autoApproved = new DispatchUpliftRequest
{
DispatchId = dispatchId,
CurrentNTE = current,
RequestedNTE = request.Amount,
VendorReason = notes,
Status = UpliftStatus.NoApprovalRequired,
RequiredTier = 0,
RequestedByVendorName = requesterName,
CreatedDate = now,
createdby = userId,
NotificationStatus = UpliftNotificationStatus.Sent,
};
await _upliftData.StageAsync(autoApproved, cancellationToken);
await StageAuditAsync(dispatch, workOrderId, userId, current, request.Amount, "uplift_auto_approved", now, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(autoApproved, requesterName, null);
}
if (await _upliftData.HasActiveAsync(dispatchId, cancellationToken))
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
var delta = request.Amount - current;
var requiredTier = delta > tier1Max ? 2 : 1;
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
var pending = new DispatchUpliftRequest
{
DispatchId = dispatchId,
CurrentNTE = current,
RequestedNTE = request.Amount,
VendorReason = notes,
Status = UpliftStatus.Pending,
RequiredTier = requiredTier,
RequestedByVendorName = requesterName,
CreatedDate = now,
createdby = userId,
ExpiresAt = expiresAt,
NotificationStatus = UpliftNotificationStatus.Pending,
};
await _upliftData.StageAsync(pending, cancellationToken);
await StageAuditAsync(dispatch, workOrderId, userId, current, request.Amount, "uplift_requested", now, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(pending, requesterName, null);
}
public async Task<WorkOrderUpliftDto?> CancelAsync(
int workOrderId,
int upliftId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken);
if (req == null)
throw new KeyNotFoundException("Uplift request not found");
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
throw new InvalidOperationException($"Cannot cancel a '{UpliftStatus.ToCanonical(req.Status)}' uplift request");
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = UpliftStatus.ToCanonical(req.Status);
req.Status = UpliftStatus.Withdrawn;
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
userId,
previous,
UpliftStatus.Withdrawn,
"uplift_cancel",
now,
cancellationToken,
isStatusTransition: true);
await _upliftData.SaveChangesAsync(cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(req, requesterName, await ResolveUserDisplayNameAsync(userId, cancellationToken));
}
public async Task<WorkOrderUpliftDto?> RevokeAsync(
int workOrderId,
int upliftId,
RevokeWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken);
if (req == null)
throw new KeyNotFoundException("Uplift request not found");
var canonical = UpliftStatus.ToCanonical(req.Status);
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (canonical == UpliftStatus.NoApprovalRequired)
{
if (string.IsNullOrWhiteSpace(userId)
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
{
throw new UpliftForbiddenException("Only the request owner can revoke an auto-approved uplift");
}
}
else if (canonical == UpliftStatus.Approved)
{
if (!user.IsInRole("Admin"))
throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift");
if (string.IsNullOrWhiteSpace(request.Reason))
throw new InvalidOperationException("A reason is required when revoking an approved uplift");
}
else
{
throw new InvalidOperationException($"Cannot revoke a '{canonical}' uplift request");
}
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = canonical;
if (canonical == UpliftStatus.Approved)
{
var oldNte = dispatch.NTEAmount ?? 0m;
dispatch.NTEAmount = req.CurrentNTE ?? oldNte;
dispatch.LastModificationTime = now;
}
req.Status = UpliftStatus.Revoked;
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.DecisionNote = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
userId,
previous,
UpliftStatus.Revoked,
"uplift_revoke",
now,
cancellationToken,
isStatusTransition: true);
await _upliftData.SaveChangesAsync(cancellationToken);
var decidedByName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName);
}
private async Task<bool> HasWorkOrderAccessAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId);
}
private async Task<string> ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(userId))
return "";
var names = await _userData.GetDisplayNamesByIdsAsync(new[] { userId });
return names.TryGetValue(userId, out var name) ? name : "";
}
private async Task StageAuditAsync(
Dispatch dispatch,
int workOrderId,
string? userId,
object oldValue,
object newValue,
string action,
DateTime now,
CancellationToken cancellationToken,
bool isStatusTransition = false)
{
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = isStatusTransition ? oldValue.ToString() : $"${Convert.ToDecimal(oldValue):F2}",
NewValue = isStatusTransition ? newValue.ToString() : $"${Convert.ToDecimal(newValue):F2}",
Action = action,
ActorType = "internal",
CreatedAt = now,
}, cancellationToken);
}
private static bool IsTerminalForUplift(string? status) =>
status is "Verified" or "Cancelled" or "Canceled" or "Refused";
}
}

View file

@ -0,0 +1,32 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderUpliftService
{
Task<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken);
Task<WorkOrderUpliftDto?> CreateAsync(
int workOrderId,
CreateWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken);
Task<WorkOrderUpliftDto?> CancelAsync(
int workOrderId,
int upliftId,
ClaimsPrincipal user,
CancellationToken cancellationToken);
Task<WorkOrderUpliftDto?> RevokeAsync(
int workOrderId,
int upliftId,
RevokeWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken);
}
}

View file

@ -218,6 +218,11 @@ public class WorkOrderBoardPendingUpliftTests
var dto = WorkOrderBoardService.MapRawRow(result.ScheduledRows[0], DateTime.UtcNow);
Assert.Equal(1, dto.PendingUpliftCount);
Assert.NotNull(dto.UpliftSummary);
Assert.True(dto.UpliftSummary!.HasUplift);
Assert.Equal(1, dto.UpliftSummary.PendingCount);
Assert.Equal("pending", dto.UpliftSummary.PrimaryStatus);
Assert.Equal(1500m, dto.UpliftSummary.Amount);
}
[Fact]

View file

@ -0,0 +1,221 @@
using Data.SeaHavenIndustries;
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 System.Security.Claims;
using Xunit;
namespace SeaHavenIndustries.Tests;
public sealed class WorkOrderUpliftServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ApprovalsOptions NewOptions() => new()
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
};
private static WorkOrderUpliftService NewService(ApplicationDbContext context)
{
return new WorkOrderUpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
TimeProvider.System,
Options.Create(NewOptions()));
}
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> 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 = "Completed",
});
var workOrder = new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
PrimaryDispatchId = 10,
AccountId = 1,
};
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
return (workOrder, context.Dispatches.Single());
}
[Fact]
public async Task ListAsync_ReturnsWorkOrderScopedUplifts()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
VendorReason = "Extra coil work",
});
await context.SaveChangesAsync();
var service = NewService(context);
var items = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None);
Assert.NotNull(items);
var uplift = Assert.Single(items!);
Assert.Equal("pending", uplift.Status);
Assert.Equal(1500m, uplift.Amount);
}
[Fact]
public async Task CreateAsync_AboveCurrentNte_CreatesPendingRequest()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 1800m, Notes = "Parts" },
Dispatcher(),
CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("pending", created!.Status);
Assert.Equal("dispatcher-1", created.RequestedById);
Assert.Equal("Pending", Assert.Single(context.DispatchUpliftRequests).Status);
}
[Fact]
public async Task CreateAsync_AtOrBelowCurrentNte_CreatesAutoApprovedRequest()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 900m, Notes = "Within limit" },
Dispatcher(),
CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("auto_approved", created!.Status);
}
[Fact]
public async Task CancelAsync_PendingRequest_Withdraws()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
createdby = "dispatcher-1",
});
await context.SaveChangesAsync();
var service = NewService(context);
var cancelled = await service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None);
Assert.NotNull(cancelled);
Assert.Equal("cancelled", cancelled!.Status);
}
[Fact]
public async Task RevokeAsync_ApprovedRequiresAdminReason()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
CurrentNTE = 1000m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
});
await context.SaveChangesAsync();
var service = NewService(context);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.RevokeAsync(
workOrder.Id,
100,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher("dispatcher-1"),
CancellationToken.None));
}
[Fact]
public async Task RevokeAsync_AdminRevokesApproved_ReturnsRevokedStatus()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
dispatch.NTEAmount = 1500m;
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
CurrentNTE = 1000m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
});
await context.SaveChangesAsync();
var service = NewService(context);
var revoked = await service.RevokeAsync(
workOrder.Id,
100,
new RevokeWorkOrderUpliftRequestDto { Reason = "Policy change" },
WorkOrderAccountTestHelpers.OrgWideAdmin("admin-1"),
CancellationToken.None);
Assert.NotNull(revoked);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(1000m, dispatch.NTEAmount);
}
}