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..b3cdbfc --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -0,0 +1,150 @@ +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); + } + + [Fact] + public async Task CancelUplift_TerminalWorkOrder_Returns400() + { + var service = new Mock(); + service.Setup(x => x.CancelAsync(7, 99, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot change uplifts on a 'Completed' work order")); + + var controller = NewController(service); + var result = await controller.CancelUplift(7, 99, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RevokeUplift_TerminalWorkOrder_Returns400() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + 7, + 99, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot change uplifts on a 'Canceled' work order")); + + var controller = NewController(service, "Admin"); + var result = await controller.RevokeUplift( + 7, + 99, + new RevokeWorkOrderUpliftRequestDto { Reason = "Completed" }, + CancellationToken.None); + + result.Should().BeOfType(); + } +} 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 83ad4a3..04feeca 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -83,7 +83,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(); @@ -138,6 +167,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..fa8d76a 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; @@ -6,6 +7,7 @@ namespace SeaHaven.DataServices.Implementation { public class UpliftDataService : IUpliftDataService { + private static readonly ConcurrentDictionary WorkOrderGates = new(); private readonly ApplicationDbContext _context; public UpliftDataService(ApplicationDbContext context) @@ -100,6 +102,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 @@ -179,6 +239,29 @@ namespace SeaHaven.DataServices.Implementation && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); } + public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + { + return ForWorkOrder(workOrderId) + .AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken); + } + + public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + { + return ForWorkOrder(workOrderId) + .Where(u => u.Status == "NoApprovalRequired") + .SumAsync(u => u.RequestedNTE, cancellationToken); + } + + public Task> GetPendingForWorkOrderAsync( + int workOrderId, + CancellationToken cancellationToken) + { + return ForWorkOrder(workOrderId) + .Include(u => u.Dispatch) + .Where(u => u.Status == "Pending" || u.Status == "ChangesRequested") + .ToListAsync(cancellationToken); + } + public async Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests @@ -237,6 +320,17 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } + private IQueryable ForWorkOrder(int workOrderId) + { + return _context.DispatchUpliftRequests.Where(u => + (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))); + } + public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) { await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken); @@ -246,5 +340,48 @@ namespace SeaHaven.DataServices.Implementation { await _context.SaveChangesAsync(cancellationToken); } + + public async Task ExecuteWorkOrderMutationAsync( + int workOrderId, + Func> work, + CancellationToken cancellationToken) + { + var gate = WorkOrderGates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync(cancellationToken) + : null; + try + { + await LockWorkOrderRowAsync(workOrderId, cancellationToken); + var result = await work(cancellationToken); + if (transaction is not null) + await transaction.CommitAsync(cancellationToken); + return result; + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(cancellationToken); + throw; + } + finally + { + gate.Release(); + } + } + + private async Task LockWorkOrderRowAsync(int workOrderId, CancellationToken cancellationToken) + { + if (_context.Database.ProviderName?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) != true) + return; + + await _context.workOrders + .FromSqlRaw( + "SELECT * FROM [workOrders] WITH (UPDLOCK, ROWLOCK, HOLDLOCK) WHERE [Id] = {0}", + workOrderId) + .Select(workOrder => workOrder.Id) + .FirstOrDefaultAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 045f1fe..1c4062b 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); @@ -14,6 +16,9 @@ namespace SeaHaven.DataServices.Interfaces // Returns null when the request, the linked evidence, or the dispatch linkage is absent. Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken); Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); + Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); // SH-101: active = Pending or ChangesRequested (the only states that block a new request). Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken); Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken); @@ -23,5 +28,9 @@ namespace SeaHaven.DataServices.Interfaces Task> GetDueForEscalationAsync(int count, CancellationToken cancellationToken); Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); + Task ExecuteWorkOrderMutationAsync( + int workOrderId, + Func> work, + CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 531dd70..f122889 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -55,6 +55,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 51d715d..e59464c 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; } @@ -69,6 +77,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/WorkOrderUpliftAllowance.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs new file mode 100644 index 0000000..2c8a97d --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs @@ -0,0 +1,19 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderUpliftAllowance + { + public const decimal StandardCapUsd = 500m; + public const decimal EmergencyCapUsd = 5000m; + + public static decimal CapFor(WorkOrderType? type) => + type == WorkOrderType.Emergency ? EmergencyCapUsd : StandardCapUsd; + + public static decimal Remaining(decimal cap, decimal consumedAutoApproved) => + Math.Max(0m, cap - consumedAutoApproved); + + public static bool AutoApproves(decimal amount, decimal remaining) => + amount <= remaining; + } +} 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/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index ec6d73d..94fe959 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -13,15 +13,21 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; + private readonly IWorkOrderUpliftService _upliftService; + private readonly IUpliftDataService _upliftData; public WorkOrderBoardCancelService( IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, - IWorkOrderAuditService auditService) + IWorkOrderAuditService auditService, + IWorkOrderUpliftService upliftService, + IUpliftDataService upliftData) { _mutationData = mutationData; _boardService = boardService; _auditService = auditService; + _upliftService = upliftService; + _upliftData = upliftData; } public async Task CancelAsync( @@ -29,28 +35,39 @@ namespace SeaHaven.Services.Implementation ClaimsPrincipal user, string? actorId) { - var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + // SH-196: run the whole cancel under the same per-work-order gate that uplift + // create uses. Previously the pending-uplift read happened outside any gate, so + // an in-flight create could commit after that read and leave a pending uplift on + // a Canceled work order, breaking "cancelling a WO with a pending uplift cancels + // that uplift in the same action". + await _upliftData.ExecuteWorkOrderMutationAsync( + workOrderId, + async ct => + { + var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, ct); - if (workOrder == null) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - if (workOrder.LifecycleStatus == LifecycleStatus.Canceled) - { - var existing = await _boardService.GetBoardRowAsync(workOrderId, user); - return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - } + // Already canceled: nothing to mutate, the row is returned below. + if (workOrder.LifecycleStatus == LifecycleStatus.Canceled) + return false; - if (workOrder.LifecycleStatus == LifecycleStatus.Completed) - throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status."); + if (workOrder.LifecycleStatus == LifecycleStatus.Completed) + throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status."); - var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? ""; - workOrder.LifecycleStatus = LifecycleStatus.Canceled; - workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled); - if (workOrder.LegacyStatus == null && workOrder.Status != null) - workOrder.LegacyStatus = workOrder.Status; + var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? ""; + workOrder.LifecycleStatus = LifecycleStatus.Canceled; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled); + if (workOrder.LegacyStatus == null && workOrder.Status != null) + workOrder.LegacyStatus = workOrder.Status; - await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); - await _mutationData.SaveAsync(CancellationToken.None); + await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct); + await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); + await _mutationData.SaveAsync(ct); + return true; + }, + CancellationToken.None); var row = await _boardService.GetBoardRowAsync(workOrderId, user); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index e94d8b1..4aa70ad 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -129,7 +129,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/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index e63c103..8a94e26 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -131,6 +131,7 @@ namespace SeaHaven.Services.Implementation RowVersion = row.RowVersion, DispatchRowVersion = row.DispatchRowVersion, PendingUpliftCount = row.PendingUpliftCount, + UpliftSummary = row.UpliftSummary, Description = extended?.Description, Trade = extended?.Trade, Problem = extended?.Problem, diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs new file mode 100644 index 0000000..6a2c388 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -0,0 +1,438 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +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 userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + var requesterName = await ResolveUserDisplayNameAsync(userId, cancellationToken); + var notes = request.Notes?.Trim() ?? ""; + var accountFilter = _accountResolver.ResolveAccountFilter(user); + + return await _upliftData.ExecuteWorkOrderMutationAsync( + workOrderId, + ct => CreateLockedAsync( + workOrderId, + request.Amount, + notes, + userId, + requesterName, + accountFilter, + ct), + cancellationToken); + } + + private async Task CreateLockedAsync( + int workOrderId, + decimal amount, + string notes, + string? userId, + string requesterName, + int? accountFilter, + CancellationToken cancellationToken) + { + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, + cancellationToken, + accountFilter); + if (workOrder?.PrimaryDispatchId is not int dispatchId) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + + if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) + { + throw new InvalidOperationException( + $"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order"); + } + + 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"); + + if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken)) + throw new InvalidOperationException("An open uplift request already exists for this work order"); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var current = dispatch.NTEAmount ?? 0m; + var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken); + var remaining = WorkOrderUpliftAllowance.Remaining( + WorkOrderUpliftAllowance.CapFor(workOrder.WorkOrderType), + consumed); + + if (WorkOrderUpliftAllowance.AutoApproves(amount, remaining)) + { + return await PersistCreatedAsync( + dispatch, + workOrderId, + userId, + requesterName, + current, + amount, + notes, + UpliftStatus.NoApprovalRequired, + requiredTier: 0, + expiresAt: null, + UpliftNotificationStatus.Sent, + "uplift_auto_approved", + now, + cancellationToken); + } + + return await PersistCreatedAsync( + dispatch, + workOrderId, + userId, + requesterName, + current, + amount, + notes, + UpliftStatus.Pending, + requiredTier: 1, + now + _approvalsOptions.EffectiveExpiration, + UpliftNotificationStatus.Pending, + "uplift_requested", + now, + cancellationToken); + } + + 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"); + + // SH-196: same terminal-lifecycle guard as revoke — the read-only dialog state + // is UX only and does not stop a direct API call. + await EnsureWorkOrderAcceptsUpliftMutationAsync(workOrderId, user, "cancel", cancellationToken); + + 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"); + + // SH-196: revoking is blocked once the work order is Completed or Canceled. + // The board dialog enforces this in the UI only, so without a service-side + // guard a direct API call could still mutate uplifts on a terminal WO. + await EnsureWorkOrderAcceptsUpliftMutationAsync(workOrderId, user, "revoke", cancellationToken); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var previous = canonical; + + // SH-196: revoking must free the work order's uplift capacity again. Create + // raises the dispatch NTE for BOTH auto-approved and approved uplifts, so + // revoke has to compensate symmetrically. Restoring only on Approved left the + // NTE raised while SumAutoApprovedAmountForWorkOrderAsync stopped counting the + // revoked request, so every create -> auto-approve -> revoke cycle compounded + // the inflation and handed back allowance that was never actually released. + if (canonical == UpliftStatus.Approved || canonical == UpliftStatus.NoApprovalRequired) + { + 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); + } + + public async Task WithdrawPendingForWorkOrderAsync( + int workOrderId, + string? actorId, + CancellationToken cancellationToken) + { + var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken); + if (pending.Count == 0) + return; + + var now = _timeProvider.GetUtcNow().UtcDateTime; + foreach (var req in pending) + { + if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn)) + continue; + + var dispatch = req.Dispatch ?? await _dispatchData.GetByIdAsync(req.DispatchId); + if (dispatch == null) + continue; + + var previous = UpliftStatus.ToCanonical(req.Status); + req.Status = UpliftStatus.Withdrawn; + req.DecidedAt = now; + req.DecidedByUserId = actorId; + req.LastModificationTime = now; + + await StageAuditAsync( + dispatch, + workOrderId, + actorId, + previous, + UpliftStatus.Withdrawn, + "uplift_cancel", + now, + cancellationToken, + isStatusTransition: true); + } + } + + private async Task PersistCreatedAsync( + Dispatch dispatch, + int workOrderId, + string? userId, + string requesterName, + decimal currentNte, + decimal amount, + string notes, + string status, + int requiredTier, + DateTime? expiresAt, + string notificationStatus, + string auditAction, + DateTime now, + CancellationToken cancellationToken) + { + var created = new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = currentNte, + RequestedNTE = amount, + VendorReason = notes, + Status = status, + RequiredTier = requiredTier, + RequestedByVendorName = requesterName, + CreatedDate = now, + createdby = userId, + ExpiresAt = expiresAt, + NotificationStatus = notificationStatus, + }; + if (status == UpliftStatus.NoApprovalRequired) + { + dispatch.NTEAmount = currentNte + amount; + dispatch.LastModificationTime = now; + } + await _upliftData.StageAsync(created, cancellationToken); + await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken); + await _upliftData.SaveChangesAsync(cancellationToken); + return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null); + } + + /// + /// SH-196: uplifts may not be mutated once the work order reaches a terminal + /// lifecycle state. Enforced in the service so a direct API call is rejected too, + /// not only the board dialog's read-only state. + /// + private async Task EnsureWorkOrderAcceptsUpliftMutationAsync( + int workOrderId, + ClaimsPrincipal user, + string action, + CancellationToken cancellationToken) + { + var accountFilter = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountFilter); + var lifecycle = workOrder?.LifecycleStatus; + + if (lifecycle == LifecycleStatus.Completed || lifecycle == LifecycleStatus.Canceled) + throw new InvalidOperationException( + $"Cannot {action} an uplift on a '{lifecycle}' work order"); + } + + 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..962ffb4 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs @@ -0,0 +1,37 @@ +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); + + Task WithdrawPendingForWorkOrderAsync( + int workOrderId, + string? actorId, + CancellationToken cancellationToken); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 0538e90..5bbec79 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -1,10 +1,15 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; +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 SeaHavenIndustries.Tests; @@ -21,7 +26,7 @@ public class WorkOrderBoardCancelServiceTests var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService(), new PassThroughUpliftData()); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } @@ -149,4 +154,334 @@ public class WorkOrderBoardCancelServiceTests Assert.Equal("CanceledReadOnly", ex.Code); } + + [Fact] + public async Task Cancel_WithdrawsPendingUpliftAndWritesAudit() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + PrimaryDispatchId = 10, + AccountId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var uplifts = new WorkOrderUpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData()); + + var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); + + Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); + Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status); + Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); + Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "StatusChanged"); + } + + [Fact] + public async Task Cancel_LateSaveFailure_LeavesPendingUpliftAndOpenWorkOrder() + { + var databaseName = Guid.NewGuid().ToString(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + await using var context = new ApplicationDbContext(options); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + PrimaryDispatchId = 10, + AccountId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var uplifts = new WorkOrderUpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var cancel = new WorkOrderBoardCancelService( + new ThrowingSaveMutationData(mutationData), + boardService, + audit, + uplifts, + new PassThroughUpliftData()); + + await Assert.ThrowsAsync(() => + cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); + + await using var verify = new ApplicationDbContext(options); + Assert.Equal(LifecycleStatus.Incomplete, Assert.Single(verify.workOrders).LifecycleStatus); + Assert.Equal("Pending", Assert.Single(verify.DispatchUpliftRequests).Status); + Assert.Empty(verify.WorkOrderAuditLogs); + } + + [Fact] + public async Task Cancel_SerializedWithCreate_DoesNotLeavePendingOnCanceledWorkOrder() + { + var databaseName = Guid.NewGuid().ToString(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + + await using (var seed = new ApplicationDbContext(options)) + { + await WorkOrderAccountTestHelpers.EnsureAccountAsync(seed); + seed.Users.Add(new ApplicationUser + { + Id = "dispatcher-1", + UserName = "dispatcher-1", + FirstName = "Alex", + LastName = "Dispatcher", + }); + seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + seed.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + NTEAmount = 1000m, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + seed.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + PrimaryDispatchId = 10, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }, + }); + await seed.SaveChangesAsync(); + } + + await using var createContext = new ApplicationDbContext(options); + await using var cancelContext = new ApplicationDbContext(options); + var createService = new WorkOrderUpliftService( + new UpliftDataService(createContext), + new DispatchDataService(createContext), + new WorkOrderDetailDataService(createContext), + WorkOrderAccountTestHelpers.Resolver(createContext), + new UserDataService(createContext), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var boardData = new WorkOrderBoardDataService(cancelContext); + var mutationData = new WorkOrderBoardMutationDataService(cancelContext); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext)); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(cancelContext)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(cancelContext), fieldLocks); + var cancelUplifts = new WorkOrderUpliftService( + new UpliftDataService(cancelContext), + new DispatchDataService(cancelContext), + new WorkOrderDetailDataService(cancelContext), + WorkOrderAccountTestHelpers.Resolver(cancelContext), + new UserDataService(cancelContext), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var cancel = new WorkOrderBoardCancelService( + mutationData, + boardService, + audit, + cancelUplifts, + new UpliftDataService(cancelContext)); + + await Task.WhenAll( + createService.CreateAsync( + 1, + new CreateWorkOrderUpliftRequestDto { Amount = 501m, Notes = "Pending" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1"), + CancellationToken.None), + cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); + + await using var verify = new ApplicationDbContext(options); + var workOrder = Assert.Single(verify.workOrders); + Assert.Equal(LifecycleStatus.Canceled, workOrder.LifecycleStatus); + Assert.DoesNotContain(verify.DispatchUpliftRequests, row => row.Status == "Pending"); + } + + private sealed class ThrowingSaveMutationData : IWorkOrderBoardMutationDataService + { + private readonly IWorkOrderBoardMutationDataService _inner; + + public ThrowingSaveMutationData(IWorkOrderBoardMutationDataService inner) + { + _inner = inner; + } + + public Task VendorExistsAsync(int vendorId, CancellationToken cancellationToken) + => _inner.VendorExistsAsync(vendorId, cancellationToken); + + public Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken) + => _inner.GetMaxWorkOrderIdAsync(cancellationToken); + + public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + => _inner.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); + + public Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken) + => _inner.GetTrackedDispatchAsync(dispatchId, workOrderId, cancellationToken); + + public Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken) + => _inner.GetTrackedDispatchByIdAsync(dispatchId, cancellationToken); + + public void TrackNewWorkOrder(WorkOrder workOrder) => _inner.TrackNewWorkOrder(workOrder); + + public void TrackNewDispatch(Dispatch dispatch) => _inner.TrackNewDispatch(dispatch); + + public void TrackWorkOrderContact(int workOrderId, int contactId, string? notes) + => _inner.TrackWorkOrderContact(workOrderId, contactId, notes); + + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) + => _inner.SetExpectedWorkOrderVersion(workOrder, version); + + public void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version) + => _inner.SetExpectedDispatchVersion(dispatch, version); + + public bool HasPendingChanges() => _inner.HasPendingChanges(); + + public Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) + => _inner.ExecuteTransactionalAsync(work, cancellationToken); + + public Task SaveAsync(CancellationToken cancellationToken) + => throw new InvalidOperationException("forced late failure"); + } + + /// + /// SH-196: the cancel flow now runs inside the per-work-order gate. Tests only need the + /// gate to invoke the work; the real serialization is exercised against SQL Server. + /// + private sealed class PassThroughUpliftData : IUpliftDataService + { + public Task ExecuteWorkOrderMutationAsync( + int workOrderId, + Func> work, + CancellationToken cancellationToken) => work(cancellationToken); + + public Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( + string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) => + throw new NotSupportedException(); + public Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetDueForEscalationAsync(int count, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SaveChangesAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class NoOpUpliftService : IWorkOrderUpliftService + { + public Task?> ListAsync( + int workOrderId, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult?>(Array.Empty()); + + public Task CreateAsync( + int workOrderId, + CreateWorkOrderUpliftRequestDto request, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task CancelAsync( + int workOrderId, + int upliftId, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task RevokeAsync( + int workOrderId, + int upliftId, + RevokeWorkOrderUpliftRequestDto request, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task WithdrawPendingForWorkOrderAsync( + int workOrderId, + string? actorId, + CancellationToken cancellationToken) => Task.CompletedTask; + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 32a890c..dc536ce 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/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 47af455..34a3cbb 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -209,6 +209,11 @@ public class WorkOrderDetailServiceTests Assert.NotNull(detail); Assert.Equal(1, detail!.Info.PendingUpliftCount); + Assert.NotNull(detail.Info.UpliftSummary); + Assert.True(detail.Info.UpliftSummary!.HasUplift); + Assert.Equal(1, detail.Info.UpliftSummary.PendingCount); + Assert.Equal("pending", detail.Info.UpliftSummary.PrimaryStatus); + Assert.Equal(1500m, detail.Info.UpliftSummary.Amount); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs new file mode 100644 index 0000000..7ec8856 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -0,0 +1,567 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +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, + WorkOrderType type = WorkOrderType.PM) + { + 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, + WorkOrderType = type, + }; + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + return (workOrder, context.Dispatches.Single(d => d.Id == 10)); + } + + [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_PmAmountAtCap_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 = 400m, Notes = "Within limit" }, + Dispatcher(), + CancellationToken.None); + + Assert.NotNull(created); + Assert.Equal("auto_approved", created!.Status); + Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + } + + [Fact] + public async Task CreateAsync_PmAmountAboveCap_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 = 501m, 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_PmSecondRequestExceedsRemainingAllowance_CreatesPending() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + var first = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "First" }, + Dispatcher(), + CancellationToken.None); + Assert.Equal("auto_approved", first!.Status); + + var second = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 200m, Notes = "Second" }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal("pending", second!.Status); + } + + [Fact] + public async Task CreateAsync_EmergencyAtCap_CreatesAutoApprovedRequest() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 5000m, Notes = "Emergency parts" }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal("auto_approved", created!.Status); + } + + [Fact] + public async Task CreateAsync_EmergencyAboveCap_CreatesPendingRequest() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 5001m, Notes = "Above emergency cap" }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal("pending", created!.Status); + } + + [Fact] + public async Task CreateAsync_PendingOnOtherDispatch_BlocksNewRequest() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + context.Dispatches.Add(new Dispatch + { + Id = 11, + VendorId = 1, + WorkOrderId = workOrder.Id, + NTEAmount = 800m, + DispatchNumber = "DIS-11", + Status = "Scheduled", + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 11, + RequestedNTE = 300m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 100m, Notes = "Second open" }, + Dispatcher(), + CancellationToken.None)); + + Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task WithdrawPendingForWorkOrderAsync_CancelsOpenRequestsAndAudits() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + createdby = "dispatcher-1", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + await service.WithdrawPendingForWorkOrderAsync(workOrder.Id, "actor-1", CancellationToken.None); + await context.SaveChangesAsync(); + + Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status); + Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); + } + + [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); + } + + [Fact] + public async Task RevokeAsync_AutoApproved_RestoresNteAndFreesAllowance() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + // Auto-approved create raises the dispatch NTE by the requested amount. + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Dispatcher(), + CancellationToken.None); + Assert.Equal("auto_approved", created!.Status); + Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + + var revoked = await service.RevokeAsync( + workOrder.Id, + created.Id, + new RevokeWorkOrderUpliftRequestDto(), + Dispatcher(), + CancellationToken.None); + + // SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise + // every create -> revoke cycle compounds NTE inflation. + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + + // The freed allowance is reusable at the full cap. + var second = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 500m, Notes = "Reuses freed allowance" }, + Dispatcher(), + CancellationToken.None); + Assert.Equal("auto_approved", second!.Status); + Assert.Equal(1500m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + } + + [Theory] + [InlineData(LifecycleStatus.Completed)] + [InlineData(LifecycleStatus.Canceled)] + public async Task RevokeAsync_TerminalWorkOrder_IsRejected(LifecycleStatus lifecycle) + { + 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", + }); + workOrder.LifecycleStatus = lifecycle; + await context.SaveChangesAsync(); + + var service = NewService(context); + + // SH-196: the board dialog's read-only state is UX only; a direct API call must + // still be rejected server-side. + await Assert.ThrowsAsync(() => service.RevokeAsync( + workOrder.Id, + 100, + new RevokeWorkOrderUpliftRequestDto { Reason = "Policy change" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("admin-1"), + CancellationToken.None)); + + Assert.Equal(1500m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + Assert.Equal("Approved", context.DispatchUpliftRequests.Single(r => r.Id == 100).Status); + } + + [Theory] + [InlineData(LifecycleStatus.Completed)] + [InlineData(LifecycleStatus.Canceled)] + public async Task CancelAsync_TerminalWorkOrder_IsRejected(LifecycleStatus lifecycle) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 101, + DispatchId = dispatch.Id, + RequestedNTE = 1600m, + CurrentNTE = 1000m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Sent", + }); + workOrder.LifecycleStatus = lifecycle; + await context.SaveChangesAsync(); + + var service = NewService(context); + + await Assert.ThrowsAsync(() => service.CancelAsync( + workOrder.Id, + 101, + Dispatcher(), + CancellationToken.None)); + + Assert.Equal("Pending", context.DispatchUpliftRequests.Single(r => r.Id == 101).Status); + } + + [Fact] + public async Task CreateAsync_ConcurrentRequests_PreserveOnePendingAndCap() + { + var databaseName = Guid.NewGuid().ToString(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + + await using (var seed = new ApplicationDbContext(options)) + { + await SeedWorkOrderAsync(seed); + } + + await using var firstContext = new ApplicationDbContext(options); + await using var secondContext = new ApplicationDbContext(options); + var first = NewService(firstContext); + var second = NewService(secondContext); + var request = new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Concurrent" }; + + var results = await Task.WhenAll( + first.CreateAsync(1, request, Dispatcher(), CancellationToken.None), + second.CreateAsync(1, request, Dispatcher(), CancellationToken.None)); + + await using var verify = new ApplicationDbContext(options); + var rows = verify.DispatchUpliftRequests.ToList(); + var autoApproved = rows.Where(row => row.Status == "NoApprovalRequired").ToList(); + var pending = rows.Where(row => row.Status == "Pending").ToList(); + + Assert.Equal(2, results.Length); + Assert.True(results.All(result => result != null)); + Assert.Single(autoApproved); + Assert.Single(pending); + Assert.True(autoApproved.Sum(row => row.RequestedNTE) <= 500m); + } + + [Fact] + public async Task RevokeAsync_OwnerRevokesAutoApproved_RestoresNteAndAllowance() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + + var revoked = await service.RevokeAsync( + workOrder.Id, + created!.Id, + new RevokeWorkOrderUpliftRequestDto(), + Dispatcher(), + CancellationToken.None); + + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + Assert.Equal(0m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None)); + } + + [Theory] + [InlineData(LifecycleStatus.Completed)] + [InlineData(LifecycleStatus.Canceled)] + public async Task RevokeAsync_TerminalWorkOrder_Throws(LifecycleStatus status) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + workOrder.LifecycleStatus = status; + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + RequestedNTE = 400m, + CurrentNTE = 1000m, + Status = "NoApprovalRequired", + RequiredTier = 0, + NotificationStatus = "Sent", + createdby = "dispatcher-1", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var ex = await Assert.ThrowsAsync(() => + service.RevokeAsync( + workOrder.Id, + 100, + new RevokeWorkOrderUpliftRequestDto(), + Dispatcher(), + CancellationToken.None)); + Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(LifecycleStatus.Completed)] + [InlineData(LifecycleStatus.Canceled)] + public async Task CancelAsync_TerminalWorkOrder_Throws(LifecycleStatus status) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + workOrder.LifecycleStatus = status; + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var ex = await Assert.ThrowsAsync(() => + service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None)); + Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); + } +}