diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 5870f16..920f0f4 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -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). /// private static readonly HashSet 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}", }; diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs new file mode 100644 index 0000000..ec436ab --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -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 upliftService, + params string[] roles) + { + var controller = new WorkOrderDetailController( + Mock.Of(), + Mock.Of(), + upliftService.Object, + Mock.Of>()); + var claims = new List { 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(); + service.Setup(x => x.ListAsync(7, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + 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().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var list = Assert.IsType(envelope.Data); + Assert.Single(list.Items); + Assert.Equal(11, list.Items[0].Id); + } + + [Fact] + public async Task CreateUplift_ReturnsCreatedItem() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var created = Assert.IsType(envelope.Data); + Assert.Equal(12, created.Id); + } + + [Fact] + public async Task CancelUplift_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.CancelAsync(7, 99, It.IsAny(), It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("Uplift request not found")); + + var controller = NewController(service); + var result = await controller.CancelUplift(7, 99, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RevokeUplift_Forbidden_Returns403() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + 7, + 99, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index 8f0d654..d975b32 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -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 _logger; public WorkOrderDetailController( IWorkOrderDetailService workOrderDetailService, - IWorkOrderCommentService workOrderCommentService) + IWorkOrderCommentService workOrderCommentService, + IWorkOrderUpliftService workOrderUpliftService, + ILogger 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 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 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 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 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") }); + } + } } } diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 6e214c8..28371b9 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -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; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 3d43fe3..eee4e96 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -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(); } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 85beebe..97642ab 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -100,6 +100,64 @@ namespace SeaHaven.DataServices.Implementation }).ToListAsync(cancellationToken); } + public async Task> 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 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> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return await (from u in _context.DispatchUpliftRequests diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 045f1fe..c5e46e6 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetByIdAsync(int id, CancellationToken cancellationToken); Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index ca27ca0..8d8ecfb 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -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( diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index de3fe67..8f78e36 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -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. /// public int PendingUpliftCount { get; set; } + /// SH-196: board/search uplift column contract. + public WorkOrderUpliftSummaryDto? UpliftSummary { get; set; } } public class WorkOrderBoardPatchRequestDto diff --git a/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs new file mode 100644 index 0000000..8a7e683 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs @@ -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 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; } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs new file mode 100644 index 0000000..7596b8d --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs @@ -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(); + } +} diff --git a/SeaHaven.Services/Implementation/UpliftStatus.cs b/SeaHaven.Services/Implementation/UpliftStatus.cs index e38d0a6..ece8681 100644 --- a/SeaHaven.Services/Implementation/UpliftStatus.cs +++ b/SeaHaven.Services/Implementation/UpliftStatus.cs @@ -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 TerminalStates = - new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired }; + new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired, Revoked, NoApprovalRequired }; private static readonly HashSet ActiveStates = new(StringComparer.Ordinal) { Pending, ChangesRequested }; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 04bf63c..a03368b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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) }; } diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs new file mode 100644 index 0000000..bd79b44 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -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) + { + _upliftData = upliftData; + _dispatchData = dispatchData; + _detailData = detailData; + _accountResolver = accountResolver; + _userData = userData; + _timeProvider = timeProvider; + _approvalsOptions = approvalsOptions.Value; + } + + public async Task?> 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 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 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 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 HasWorkOrderAccessAsync( + int workOrderId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId); + } + + private async Task 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"; + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs b/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs new file mode 100644 index 0000000..84d6940 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderUpliftService + { + Task?> ListAsync( + int workOrderId, + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task CreateAsync( + int workOrderId, + CreateWorkOrderUpliftRequestDto request, + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task CancelAsync( + int workOrderId, + int upliftId, + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task RevokeAsync( + int workOrderId, + int upliftId, + RevokeWorkOrderUpliftRequestDto request, + ClaimsPrincipal user, + CancellationToken cancellationToken); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 92c4d36..2040d88 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -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] diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs new file mode 100644 index 0000000..21b010a --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -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() + .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(() => + 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); + } +}