From b81cfbb005ada8b006f9303ebb078aa9b5f879b9 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Thu, 13 Aug 2026 14:49:56 -0300 Subject: [PATCH 1/7] feat(work-orders): WO-scoped uplift endpoints and board summary (SH-196) Expose workorders/{id}/uplifts list/create/cancel/revoke for the SH-196 dialog, aggregate upliftSummary on board rows, and add service/controller regression tests. --- .../WorkOrderRouteContractTests.cs | 8 +- .../WorkOrderUpliftControllerTests.cs | 115 +++++++ .../Controllers/WorkOrderDetailController.cs | 110 ++++++- .../Models/VendorPortalReadModels.cs | 5 + .../Helpers/WorkOrderBoardProjection.cs | 34 +- .../Implementation/UpliftDataService.cs | 58 ++++ .../Interfaces/IUpliftDataService.cs | 2 + .../Interfaces/WorkOrderBoardModels.cs | 3 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 10 + SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs | 32 ++ .../Helpers/WorkOrderUpliftContractMapper.cs | 100 ++++++ .../Implementation/UpliftStatus.cs | 3 +- .../Implementation/WorkOrderBoardService.cs | 3 +- .../Implementation/WorkOrderUpliftService.cs | 299 ++++++++++++++++++ .../Interfaces/IWorkOrderUpliftService.cs | 32 ++ .../WorkOrderBoardServiceTests.cs | 5 + .../WorkOrderUpliftServiceTests.cs | 221 +++++++++++++ 17 files changed, 1034 insertions(+), 6 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderUpliftService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs 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); + } +} From 36ef0b00f5eb387d6b2fdfa8ade947e5b830f854 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 13 Aug 2026 16:11:36 -0300 Subject: [PATCH 2/7] feat(work-orders): persist additionalContacts on create, board GET and PATCH (SH-218) Add JSON column, DTO/mapper, create + PATCH field, board projection, FluentValidation, and regression tests for additional POC contacts round-trip. --- ...60000_SH218_WorkOrderAdditionalContacts.cs | 33 +++++ .../ApplicationDbContextModelSnapshot.cs | 4 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 3 + .../Helpers/WorkOrderBoardProjection.cs | 2 + .../Interfaces/WorkOrderBoardModels.cs | 1 + .../DTOs/WorkOrderAdditionalContactDto.cs | 4 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 1 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 1 + .../WorkOrderAdditionalContactsMapper.cs | 120 ++++++++++++++++++ .../Helpers/WorkOrderBoardFieldNames.cs | 3 + .../WorkOrderBoardCreateService.cs | 4 + .../Implementation/WorkOrderBoardService.cs | 1 + .../WorkOrderBoardUpdateService.cs | 13 ++ .../WorkOrderBoardCreateValidation.cs | 18 +++ .../WorkOrderBoardCreateServiceTests.cs | 32 +++++ .../WorkOrderBoardUpdateServiceTests.cs | 50 ++++++++ 16 files changed, 290 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.cs b/Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.cs new file mode 100644 index 0000000..2ae18e8 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260813160000_SH218_WorkOrderAdditionalContacts.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260813160000_SH218_WorkOrderAdditionalContacts")] + public partial class SH218_WorkOrderAdditionalContacts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AdditionalContacts", + table: "workOrders", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AdditionalContacts", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 813853c..45bd51c 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2496,6 +2496,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(2000) .HasColumnType("nvarchar(2000)"); + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + b.Property("PocPhone") .HasMaxLength(50) .HasColumnType("nvarchar(50)"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 59f2390..ec2e450 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -54,6 +54,9 @@ namespace Data.SeaHavenIndustries public string? PocPhone { get; set; } [MaxLength(2000)] public string? PocNotes { get; set; } + /// JSON array of additional POC contacts beyond primary (name, phone, notes?). + [MaxLength(4000)] + public string? AdditionalContacts { get; set; } /// JSON array of extra service labels beyond primary (Trade). [MaxLength(2000)] public string? ExtraServices { get; set; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 3d43fe3..afb9a82 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers w.Problem, w.ServiceNotes, w.ExtraServices, + w.AdditionalContacts, w.DocStatus, w.CompletedDate, w.FlagColor, @@ -127,6 +128,7 @@ namespace SeaHaven.DataServices.Helpers w.Problem, w.ServiceNotes, w.ExtraServices, + w.AdditionalContacts, w.DocStatus, w.CompletedDate, w.FlagColor, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index ca27ca0..cc78030 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -46,6 +46,7 @@ namespace SeaHaven.DataServices.Interfaces string? Problem, string? ServiceNotes, string? ExtraServices, + string? AdditionalContacts, DocStatus? DocStatus, DateTime? CompletedDate, string? FlagColor, diff --git a/SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs b/SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs new file mode 100644 index 0000000..25c33d1 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderAdditionalContactDto.cs @@ -0,0 +1,4 @@ +namespace SeaHaven.Services.DTOs +{ + public record WorkOrderAdditionalContactDto(string Name, string Phone, string? Notes); +} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index de3fe67..b720a9e 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -34,6 +34,7 @@ namespace SeaHaven.Services.DTOs public string? PocName { get; set; } public string? PocPhone { get; set; } public string? PocNotes { get; set; } + public List? AdditionalContacts { get; set; } public LifecycleStatus? LifecycleStatus { get; set; } public string? LifecycleStatusLabel { get; set; } public string? LegacyStatus { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 3ca672c..0087293 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -41,6 +41,7 @@ namespace SeaHaven.Services.DTOs public string? PocName { get; set; } public string? PocPhone { get; set; } public string? PocNotes { get; set; } + public List? AdditionalContacts { get; set; } public string? TechPhone { get; set; } public string? VendorNotes { get; set; } } diff --git a/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs new file mode 100644 index 0000000..4fc2bc9 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderAdditionalContactsMapper + { + private const int MaxNameLength = 200; + private const int MaxPhoneLength = 50; + private const int MaxNotesLength = 2000; + private const int MaxSerializedLength = 4000; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static List? ParseJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var parsed = JsonSerializer.Deserialize>(json, JsonOptions); + return parsed is { Count: > 0 } ? parsed : null; + } + catch (JsonException) + { + return null; + } + } + + public static string? SerializeForStorage(IReadOnlyList? contacts) + { + var normalized = NormalizeList(contacts); + if (normalized.Count == 0) + return null; + + var json = JsonSerializer.Serialize(normalized, JsonOptions); + if (json.Length > MaxSerializedLength) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "additionalContacts must serialize to at most 4000 characters."); + } + + return json; + } + + public static string? SerializeForPatch(string? jsonValue) + { + if (string.IsNullOrWhiteSpace(jsonValue)) + return null; + + List? parsed; + try + { + parsed = JsonSerializer.Deserialize>(jsonValue.Trim(), JsonOptions); + } + catch (JsonException) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "additionalContacts must be a JSON array of { name, phone, notes? }."); + } + + return SerializeForStorage(parsed); + } + + public static List NormalizeList(IReadOnlyList? contacts) + { + var normalized = new List(); + if (contacts == null || contacts.Count == 0) + return normalized; + + foreach (var raw in contacts) + { + var name = raw.Name?.Trim() ?? ""; + var phone = raw.Phone?.Trim() ?? ""; + var notes = raw.Notes?.Trim(); + + if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(phone)) + continue; + + if (name.Length > MaxNameLength) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Each additional contact name must be at most 200 characters."); + } + + if (phone.Length > MaxPhoneLength) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Each additional contact phone must be at most 50 characters."); + } + + if (notes != null && notes.Length > MaxNotesLength) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Each additional contact notes must be at most 2000 characters."); + } + + normalized.Add(new WorkOrderAdditionalContactDto( + name, + phone, + string.IsNullOrEmpty(notes) ? null : notes)); + } + + return normalized; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index 97f7744..45ae58f 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -19,6 +19,7 @@ namespace SeaHaven.Services.Helpers public const string Pm = "pm"; public const string ServiceNotes = "serviceNotes"; public const string ExtraServices = "extraServices"; + public const string AdditionalContacts = "additionalContacts"; public const string FlagColor = "flagColor"; public const string RescheduleCount = "RescheduleCount"; @@ -48,6 +49,7 @@ namespace SeaHaven.Services.Helpers Pm, ServiceNotes, ExtraServices, + AdditionalContacts, FlagColor }; @@ -89,6 +91,7 @@ namespace SeaHaven.Services.Helpers Pm => "Trade", ServiceNotes => "ServiceNotes", ExtraServices => "ExtraServices", + AdditionalContacts => "AdditionalContacts", FlagColor => "FlagColor", _ => canonical }; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index ee32e53..593b67b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -59,6 +59,7 @@ namespace SeaHaven.Services.Implementation var pocName = TrimOrNull(request.PocName); var pocPhone = TrimOrNull(request.PocPhone); var pocNotes = TrimOrNull(request.PocNotes); + var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts); var techPhone = TrimOrNull(request.TechPhone); var vendorNotes = TrimOrNull(request.VendorNotes); var customer = TrimOrNull(request.Customer); @@ -87,6 +88,7 @@ namespace SeaHaven.Services.Implementation PocName = pocName, PocPhone = pocPhone, PocNotes = pocNotes, + AdditionalContacts = additionalContactsJson, LocationId = request.LocationId, DueDate = request.DueDate?.Date, AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(), @@ -128,6 +130,8 @@ namespace SeaHaven.Services.Implementation changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone)); if (workOrder.PocNotes != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes)); + if (workOrder.AdditionalContacts != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("AdditionalContacts", "", workOrder.AdditionalContacts)); if (workOrder.TechPhone != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone)); if (workOrder.VendorNotes != null) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 04bf63c..50d4261 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -121,6 +121,7 @@ namespace SeaHaven.Services.Implementation Pm = row.Trade ?? row.Problem, ServiceNotes = row.ServiceNotes, ExtraServices = ParseExtraServices(row.ExtraServices), + AdditionalContacts = WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts), DocStatus = row.DocStatus, CompletedDate = row.CompletedDate, FlagColor = row.FlagColor, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 3e8c108..5b737dc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -157,6 +157,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.Pm => new List { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, WorkOrderBoardFieldNames.ServiceNotes => new List { ApplyServiceNotes(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ExtraServices => new List { ApplyExtraServices(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.AdditionalContacts => new List { ApplyAdditionalContacts(workOrder, value, auditField) }, WorkOrderBoardFieldNames.FlagColor => new List { ApplyFlagColor(workOrder, value, auditField) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; @@ -321,6 +322,18 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, normalizedJson); } + private static FieldChange ApplyAdditionalContacts(WorkOrder workOrder, string? value, string auditField) + { + var normalizedJson = WorkOrderAdditionalContactsMapper.SerializeForPatch(value); + + var old = workOrder.AdditionalContacts; + if (old == normalizedJson) + return FieldChange.Unchanged(auditField); + + workOrder.AdditionalContacts = normalizedJson; + return FieldChange.ForField(auditField, old, normalizedJson); + } + private static List NormalizeExtraServiceList(List? extras) { var normalized = new List(); diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 8d0564d..48842c7 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -78,6 +78,24 @@ namespace SeaHaven.Services.Validation RuleForEach(x => x.ExtraServices) .MaximumLength(128) .When(x => x.ExtraServices != null); + + RuleForEach(x => x.AdditionalContacts) + .ChildRules(contact => + { + contact.RuleFor(c => c.Name) + .MaximumLength(200) + .When(c => !string.IsNullOrEmpty(c.Name)); + contact.RuleFor(c => c.Phone) + .MaximumLength(50) + .When(c => !string.IsNullOrEmpty(c.Phone)); + contact.RuleFor(c => c.Notes) + .MaximumLength(2000) + .When(c => !string.IsNullOrEmpty(c.Notes)); + contact.RuleFor(c => c) + .Must(c => !string.IsNullOrWhiteSpace(c.Name) || !string.IsNullOrWhiteSpace(c.Phone)) + .WithMessage("Each additional contact must include a name or phone."); + }) + .When(x => x.AdditionalContacts != null); } private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index bd5f2bf..7bc43da 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -4,7 +4,9 @@ using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; namespace SeaHavenIndustries.Tests; @@ -351,4 +353,34 @@ public class WorkOrderBoardCreateServiceTests Assert.Equal("VendorNotFound", ex.Code); } + + [Fact] + public async Task Create_AdditionalContacts_PersistsAndReturnsOnBoardRow() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + PocName = "Primary Lead", + AdditionalContacts = new List + { + new("Backup Lead", "+1 555-0101", "After hours"), + new("Security Desk", "+1 555-0102", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal("Primary Lead", result.PocName); + Assert.NotNull(result.AdditionalContacts); + Assert.Equal(2, result.AdditionalContacts!.Count); + Assert.Equal("Backup Lead", result.AdditionalContacts[0].Name); + Assert.Equal("+1 555-0101", result.AdditionalContacts[0].Phone); + Assert.Equal("After hours", result.AdditionalContacts[0].Notes); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.NotNull(wo.AdditionalContacts); + Assert.Contains("Backup Lead", wo.AdditionalContacts); + Assert.Contains("Security Desk", wo.AdditionalContacts); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index b775ee6..f7705bf 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -354,6 +354,56 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("InvalidValue", ex.Code); } + [Fact] + public async Task PatchField_UpdatesAdditionalContacts() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.NotNull(result.AdditionalContacts); + Assert.Single(result.AdditionalContacts!); + Assert.Equal("Jane Backup", result.AdditionalContacts![0].Name); + Assert.Contains("Jane Backup", (await context.workOrders.FindAsync(1))!.AdditionalContacts); + } + + [Fact] + public async Task PatchField_InvalidAdditionalContactsJson_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "not-json", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + [Fact] public async Task PatchField_SetsFlagColor_CanonicalUppercase() { From 4c15669affc7487abd2295a67c498b6b168e3dde Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Fri, 14 Aug 2026 10:36:35 -0300 Subject: [PATCH 3/7] fix(work-orders): enforce SH-196 cumulative allowance and one pending per WO Auto-approval now uses the WO-scoped $500/$5,000 Emergency cap instead of dispatch NTE, rejects a second open request across dispatches, and cancelling a WO withdraws pending uplifts with audit. --- .../Implementation/UpliftDataService.cs | 34 ++++ .../Interfaces/IUpliftDataService.cs | 3 + .../Helpers/WorkOrderUpliftAllowance.cs | 19 +++ .../WorkOrderBoardCancelService.cs | 6 +- .../Implementation/WorkOrderUpliftService.cs | 158 +++++++++++++----- .../Interfaces/IWorkOrderUpliftService.cs | 5 + .../WorkOrderBoardCancelServiceTests.cs | 102 ++++++++++- .../WorkOrderUpliftServiceTests.cs | 135 ++++++++++++++- 8 files changed, 408 insertions(+), 54 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 97642ab..b65093e 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -237,6 +237,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 @@ -295,6 +318,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); diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index c5e46e6..b64bc68 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -16,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); 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/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index ec6d73d..e847b23 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -13,15 +13,18 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; + private readonly IWorkOrderUpliftService _upliftService; public WorkOrderBoardCancelService( IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, - IWorkOrderAuditService auditService) + IWorkOrderAuditService auditService, + IWorkOrderUpliftService upliftService) { _mutationData = mutationData; _boardService = boardService; _auditService = auditService; + _upliftService = upliftService; } public async Task CancelAsync( @@ -49,6 +52,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; + await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, CancellationToken.None); await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); await _mutationData.SaveAsync(CancellationToken.None); diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index bd79b44..1111186 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -77,59 +77,53 @@ namespace SeaHaven.Services.Implementation 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 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() ?? ""; + var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken); + var remaining = WorkOrderUpliftAllowance.Remaining( + WorkOrderUpliftAllowance.CapFor(workOrder.WorkOrderType), + consumed); - if (request.Amount <= current) + if (WorkOrderUpliftAllowance.AutoApproves(request.Amount, remaining)) { - 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); + return await PersistCreatedAsync( + dispatch, + workOrderId, + userId, + requesterName, + current, + request.Amount, + notes, + UpliftStatus.NoApprovalRequired, + requiredTier: 0, + expiresAt: null, + UpliftNotificationStatus.Sent, + "uplift_auto_approved", + now, + cancellationToken); } - 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); + return await PersistCreatedAsync( + dispatch, + workOrderId, + userId, + requesterName, + current, + request.Amount, + notes, + UpliftStatus.Pending, + requiredTier: 1, + now + _approvalsOptions.EffectiveExpiration, + UpliftNotificationStatus.Pending, + "uplift_requested", + now, + cancellationToken); } public async Task CancelAsync( @@ -251,6 +245,82 @@ namespace SeaHaven.Services.Implementation 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); + } + + await _upliftData.SaveChangesAsync(cancellationToken); + } + + 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, + }; + 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); + } + private async Task HasWorkOrderAccessAsync( int workOrderId, ClaimsPrincipal user, diff --git a/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs b/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs index 84d6940..962ffb4 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs @@ -28,5 +28,10 @@ namespace SeaHaven.Services.Interfaces 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..1f8e2b0 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -1,10 +1,14 @@ 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.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -21,7 +25,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()); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } @@ -149,4 +153,100 @@ 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); + + 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"); + } + + 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/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 21b010a..0ac7d7a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; @@ -43,7 +44,9 @@ public sealed class WorkOrderUpliftServiceTests private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1") => WorkOrderAccountTestHelpers.OrgWideAdmin(userId); - private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(ApplicationDbContext context) + private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( + ApplicationDbContext context, + WorkOrderType type = WorkOrderType.PM) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -69,10 +72,11 @@ public sealed class WorkOrderUpliftServiceTests InternalWONumber = "10000000001", PrimaryDispatchId = 10, AccountId = 1, + WorkOrderType = type, }; context.workOrders.Add(workOrder); await context.SaveChangesAsync(); - return (workOrder, context.Dispatches.Single()); + return (workOrder, context.Dispatches.Single(d => d.Id == 10)); } [Fact] @@ -102,7 +106,7 @@ public sealed class WorkOrderUpliftServiceTests } [Fact] - public async Task CreateAsync_AboveCurrentNte_CreatesPendingRequest() + public async Task CreateAsync_PmAmountAtCap_CreatesAutoApprovedRequest() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); @@ -110,7 +114,24 @@ public sealed class WorkOrderUpliftServiceTests var created = await service.CreateAsync( workOrder.Id, - new CreateWorkOrderUpliftRequestDto { Amount = 1800m, Notes = "Parts" }, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Dispatcher(), + CancellationToken.None); + + Assert.NotNull(created); + Assert.Equal("auto_approved", created!.Status); + } + + [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); @@ -121,22 +142,120 @@ public sealed class WorkOrderUpliftServiceTests } [Fact] - public async Task CreateAsync_AtOrBelowCurrentNte_CreatesAutoApprovedRequest() + public async Task CreateAsync_PmSecondRequestExceedsRemainingAllowance_CreatesPending() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); var service = NewService(context); - var created = await service.CreateAsync( + var first = await service.CreateAsync( workOrder.Id, - new CreateWorkOrderUpliftRequestDto { Amount = 900m, Notes = "Within limit" }, + 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.NotNull(created); 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); + + Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status); + Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); + } + [Fact] public async Task CancelAsync_PendingRequest_Withdraws() { From 36093659398a22653f170128f7b0c22ae80cfab1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 18 Aug 2026 10:03:12 -0300 Subject: [PATCH 4/7] fix(work-orders): map additionalContacts on detail GET (SH-218) Copy contacts into MapInfo so slide-over round-trips create/PATCH, and require name plus phone on retained entries while dropping blank placeholders. --- .../WorkOrderAdditionalContactsMapper.cs | 7 ++ .../Implementation/WorkOrderDetailService.cs | 1 + .../WorkOrderBoardCreateValidation.cs | 14 ++- .../WorkOrderBoardCreateServiceTests.cs | 108 ++++++++++++++++++ .../WorkOrderBoardUpdateServiceTests.cs | 98 ++++++++++++++++ .../WorkOrderPhase6Tests.cs | 65 +++++++++++ 6 files changed, 291 insertions(+), 2 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs index 4fc2bc9..be94839 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAdditionalContactsMapper.cs @@ -87,6 +87,13 @@ namespace SeaHaven.Services.Helpers if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(phone)) continue; + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(phone)) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Each additional contact must include a name and phone."); + } + if (name.Length > MaxNameLength) { throw new WorkOrderBoardValidationException( diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 0bb6eac..92e581d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -102,6 +102,7 @@ namespace SeaHaven.Services.Implementation PocName = row.PocName, PocPhone = row.PocPhone, PocNotes = row.PocNotes, + AdditionalContacts = row.AdditionalContacts, LifecycleStatus = row.LifecycleStatus, LifecycleStatusLabel = row.LifecycleStatusLabel, LegacyStatus = row.LegacyStatus, diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 48842c7..2025b4b 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -92,8 +92,8 @@ namespace SeaHaven.Services.Validation .MaximumLength(2000) .When(c => !string.IsNullOrEmpty(c.Notes)); contact.RuleFor(c => c) - .Must(c => !string.IsNullOrWhiteSpace(c.Name) || !string.IsNullOrWhiteSpace(c.Phone)) - .WithMessage("Each additional contact must include a name or phone."); + .Must(HasNameAndPhoneWhenPresent) + .WithMessage("Each additional contact must include a name and phone."); }) .When(x => x.AdditionalContacts != null); } @@ -108,5 +108,15 @@ namespace SeaHaven.Services.Validation return !string.IsNullOrWhiteSpace(request.PrimaryService) || !string.IsNullOrWhiteSpace(request.Trade); } + + private static bool HasNameAndPhoneWhenPresent(WorkOrderAdditionalContactDto contact) + { + var hasName = !string.IsNullOrWhiteSpace(contact.Name); + var hasPhone = !string.IsNullOrWhiteSpace(contact.Phone); + if (!hasName && !hasPhone) + return true; + + return hasName && hasPhone; + } } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 7bc43da..136159b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -383,4 +383,112 @@ public class WorkOrderBoardCreateServiceTests Assert.Contains("Backup Lead", wo.AdditionalContacts); Assert.Contains("Security Desk", wo.AdditionalContacts); } + + [Fact] + public async Task Create_AdditionalContacts_EmptyList_SucceedsWithoutContacts() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AdditionalContacts = new List() + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Null(result.AdditionalContacts); + } + + [Fact] + public async Task Create_AdditionalContacts_DropsBlankRows_PreservesOrder() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("", "", null), + new("Backup Lead", "+1 555-0101", "After hours"), + new(" ", " ", null), + new("Security Desk", "+1 555-0102", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.NotNull(result.AdditionalContacts); + Assert.Equal(2, result.AdditionalContacts!.Count); + Assert.Equal("Backup Lead", result.AdditionalContacts[0].Name); + Assert.Equal("Security Desk", result.AdditionalContacts[1].Name); + } + + [Fact] + public async Task Create_AdditionalContacts_NameOnly_Throws() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("Backup Lead", "", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Contains("name and phone", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Create_AdditionalContacts_PhoneOnly_Throws() + { + var (_, service) = CreateSut(); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("", "+1 555-0101", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + } + + [Fact] + public async Task Create_AdditionalContacts_PersistsAndReturnsOnDetail() + { + var (context, service) = CreateSut(); + var created = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + AdditionalContacts = new List + { + new("Backup Lead", "+1 555-0101", "After hours"), + new("Security Desk", "+1 555-0102", null) + } + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var detail = new WorkOrderDetailService( + new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver), + new WorkOrderDetailDataService(context), + new CompletionDocTemplateDataService(context), + new UserDataService(context), + resolver); + + var result = await detail.GetDetailAsync(created.Id, WorkOrderAccountTestHelpers.AccountUser()); + + Assert.NotNull(result); + Assert.NotNull(result!.Info.AdditionalContacts); + Assert.Equal(2, result.Info.AdditionalContacts!.Count); + Assert.Equal("Backup Lead", result.Info.AdditionalContacts[0].Name); + Assert.Equal("+1 555-0101", result.Info.AdditionalContacts[0].Phone); + Assert.Equal("Security Desk", result.Info.AdditionalContacts[1].Name); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index f7705bf..0a2822e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -404,6 +404,104 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("InvalidValue", ex.Code); } + [Fact] + public async Task PatchField_AdditionalContacts_NameOnly_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "[{\"name\":\"Jane Backup\",\"phone\":\"\"}]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_AdditionalContacts_PhoneOnly_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "[{\"name\":\"\",\"phone\":\"+1 555-0101\"}]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_AdditionalContacts_DropsBlankRows_PreservesOrder() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "[{\"name\":\"\",\"phone\":\"\"},{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(2, result.AdditionalContacts!.Count); + Assert.Equal("Jane Backup", result.AdditionalContacts[0].Name); + Assert.Equal("Security Desk", result.AdditionalContacts[1].Name); + } + + [Fact] + public async Task PatchField_AdditionalContacts_EmptyArray_Clears() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + AdditionalContacts = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\"}]", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "[]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(result.AdditionalContacts); + Assert.Null((await context.workOrders.FindAsync(1))!.AdditionalContacts); + } + [Fact] public async Task PatchField_SetsFlagColor_CanonicalUppercase() { diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d6bd47e..e9f4f62 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -210,6 +210,71 @@ public class WorkOrderDetailServiceTests Assert.Equal(1, detail!.Info.PendingUpliftCount); } + [Fact] + public async Task GetDetail_ReturnsAdditionalContactsFromBoardRow() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000218", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(1), + AdditionalContacts = + "[{\"name\":\"Backup Lead\",\"phone\":\"+1 555-0101\",\"notes\":\"After hours\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.NotNull(detail!.Info.AdditionalContacts); + Assert.Equal(2, detail.Info.AdditionalContacts!.Count); + Assert.Equal("Backup Lead", detail.Info.AdditionalContacts[0].Name); + Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone); + Assert.Equal("After hours", detail.Info.AdditionalContacts[0].Notes); + Assert.Equal("Security Desk", detail.Info.AdditionalContacts[1].Name); + Assert.Equal("+1 555-0102", detail.Info.AdditionalContacts[1].Phone); + } + + [Fact] + public async Task GetDetail_ReflectsPatchedAdditionalContacts() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "10000000219", + LifecycleStatus = LifecycleStatus.Incomplete, + AdditionalContacts = "[{\"name\":\"Old Backup\",\"phone\":\"+1 555-0000\"}]", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardData = new WorkOrderBoardDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); + + await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AdditionalContacts, + Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]", + WorkOrderVersion = Convert.ToBase64String(wo.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, "actor-1"); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.Single(detail!.Info.AdditionalContacts!); + Assert.Equal("Jane Backup", detail.Info.AdditionalContacts![0].Name); + Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone); + Assert.Equal("Call first", detail.Info.AdditionalContacts[0].Notes); + } + [Fact] public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames() { From aeface594af5bf31ac80040480890ca461397a93 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 10:20:05 -0300 Subject: [PATCH 5/7] fix(work-orders): serialize uplift create and atomic cancel (SH-196) --- .../Implementation/UpliftDataService.cs | 45 +++++++ .../Interfaces/IUpliftDataService.cs | 4 + .../Implementation/WorkOrderDetailService.cs | 1 + .../Implementation/WorkOrderUpliftService.cs | 45 +++++-- .../WorkOrderBoardCancelServiceTests.cs | 113 ++++++++++++++++++ .../WorkOrderPhase6Tests.cs | 5 + .../WorkOrderUpliftServiceTests.cs | 37 ++++++ 7 files changed, 241 insertions(+), 9 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index b65093e..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) @@ -338,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 b64bc68..1c4062b 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -28,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.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 0bb6eac..5fa8537 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -129,6 +129,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 index 1111186..45ff8ea 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -63,10 +63,37 @@ namespace SeaHaven.Services.Implementation 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, - _accountResolver.ResolveAccountFilter(user)); + accountFilter); if (workOrder?.PrimaryDispatchId is not int dispatchId) throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); @@ -80,17 +107,14 @@ namespace SeaHaven.Services.Implementation if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken)) throw new InvalidOperationException("An open uplift request already exists for this work order"); - 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() ?? ""; var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken); var remaining = WorkOrderUpliftAllowance.Remaining( WorkOrderUpliftAllowance.CapFor(workOrder.WorkOrderType), consumed); - if (WorkOrderUpliftAllowance.AutoApproves(request.Amount, remaining)) + if (WorkOrderUpliftAllowance.AutoApproves(amount, remaining)) { return await PersistCreatedAsync( dispatch, @@ -98,7 +122,7 @@ namespace SeaHaven.Services.Implementation userId, requesterName, current, - request.Amount, + amount, notes, UpliftStatus.NoApprovalRequired, requiredTier: 0, @@ -115,7 +139,7 @@ namespace SeaHaven.Services.Implementation userId, requesterName, current, - request.Amount, + amount, notes, UpliftStatus.Pending, requiredTier: 1, @@ -281,8 +305,6 @@ namespace SeaHaven.Services.Implementation cancellationToken, isStatusTransition: true); } - - await _upliftData.SaveChangesAsync(cancellationToken); } private async Task PersistCreatedAsync( @@ -315,6 +337,11 @@ namespace SeaHaven.Services.Implementation 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); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 1f8e2b0..2aae357 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -3,6 +3,7 @@ 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; @@ -214,6 +215,118 @@ public class WorkOrderBoardCancelServiceTests 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); + + 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); + } + + 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"); + } + private sealed class NoOpUpliftService : IWorkOrderUpliftService { public Task?> ListAsync( diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d6bd47e..b5dae37 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -208,6 +208,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 index 0ac7d7a..9ca211e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -120,6 +120,7 @@ public sealed class WorkOrderUpliftServiceTests Assert.NotNull(created); Assert.Equal("auto_approved", created!.Status); + Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); } [Fact] @@ -251,6 +252,7 @@ public sealed class WorkOrderUpliftServiceTests 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"); @@ -337,4 +339,39 @@ public sealed class WorkOrderUpliftServiceTests Assert.Equal("revoked", revoked!.Status); Assert.Equal(1000m, dispatch.NTEAmount); } + + [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); + } } From 5ea5a1d702da667d3475c9a547076ce044b2aa2c Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 17:39:11 -0300 Subject: [PATCH 6/7] fix(work-orders): restore NTE on auto-approved revoke and serialize cancel --- .../WorkOrderUpliftControllerTests.cs | 35 +++++++ .../WorkOrderBoardCancelService.cs | 23 ++++- .../Implementation/WorkOrderUpliftService.cs | 33 ++++++- .../WorkOrderBoardCancelServiceTests.cs | 98 ++++++++++++++++++- .../WorkOrderUpliftServiceTests.cs | 85 ++++++++++++++++ 5 files changed, 266 insertions(+), 8 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index ec436ab..b3cdbfc 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -112,4 +112,39 @@ public sealed class WorkOrderUpliftControllerTests 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/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index e847b23..f0347f2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -14,17 +14,20 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderUpliftService _upliftService; + private readonly IUpliftDataService _upliftData; public WorkOrderBoardCancelService( IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, IWorkOrderAuditService auditService, - IWorkOrderUpliftService upliftService) + IWorkOrderUpliftService upliftService, + IUpliftDataService upliftData) { _mutationData = mutationData; _boardService = boardService; _auditService = auditService; _upliftService = upliftService; + _upliftData = upliftData; } public async Task CancelAsync( @@ -32,7 +35,19 @@ namespace SeaHaven.Services.Implementation ClaimsPrincipal user, string? actorId) { - var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + return await _upliftData.ExecuteWorkOrderMutationAsync( + workOrderId, + ct => CancelLockedAsync(workOrderId, user, actorId, ct), + CancellationToken.None); + } + + private async Task CancelLockedAsync( + int workOrderId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken) + { + var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -52,9 +67,9 @@ namespace SeaHaven.Services.Implementation if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; - await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, CancellationToken.None); + await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, cancellationToken); await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); - await _mutationData.SaveAsync(CancellationToken.None); + await _mutationData.SaveAsync(cancellationToken); var row = await _boardService.GetBoardRowAsync(workOrderId, user); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 45ff8ea..d53b7b9 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; @@ -97,6 +98,12 @@ namespace SeaHaven.Services.Implementation 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 change uplifts on a '{workOrder.LifecycleStatus}' work order"); + } + var dispatch = await _dispatchData.GetByIdAsync(dispatchId); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); @@ -159,6 +166,8 @@ namespace SeaHaven.Services.Implementation if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; + await EnsureWorkOrderAllowsUpliftMutationAsync(workOrderId, user, cancellationToken); + var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); @@ -204,6 +213,8 @@ namespace SeaHaven.Services.Implementation if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; + await EnsureWorkOrderAllowsUpliftMutationAsync(workOrderId, user, cancellationToken); + var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); @@ -239,7 +250,7 @@ namespace SeaHaven.Services.Implementation var now = _timeProvider.GetUtcNow().UtcDateTime; var previous = canonical; - if (canonical == UpliftStatus.Approved) + if (canonical == UpliftStatus.Approved || canonical == UpliftStatus.NoApprovalRequired) { var oldNte = dispatch.NTEAmount ?? 0m; dispatch.NTEAmount = req.CurrentNTE ?? oldNte; @@ -348,6 +359,26 @@ namespace SeaHaven.Services.Implementation return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null); } + private async Task EnsureWorkOrderAllowsUpliftMutationAsync( + int workOrderId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountFilter = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, + cancellationToken, + accountFilter); + if (workOrder == null) + throw new KeyNotFoundException("Work order not found"); + + if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) + { + throw new InvalidOperationException( + $"Cannot change uplifts on a '{workOrder.LifecycleStatus}' work order"); + } + } + private async Task HasWorkOrderAccessAsync( int workOrderId, ClaimsPrincipal user, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 2aae357..42d1fa6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -26,7 +26,12 @@ 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, new NoOpUpliftService()); + var cancel = new WorkOrderBoardCancelService( + mutationData, + boardService, + audit, + new NoOpUpliftService(), + new UpliftDataService(context)); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } @@ -205,7 +210,7 @@ public class WorkOrderBoardCancelServiceTests new UserDataService(context), TimeProvider.System, Options.Create(new ApprovalsOptions())); - var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new UpliftDataService(context)); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); @@ -270,7 +275,8 @@ public class WorkOrderBoardCancelServiceTests new ThrowingSaveMutationData(mutationData), boardService, audit, - uplifts); + uplifts, + new UpliftDataService(context)); await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); @@ -281,6 +287,92 @@ public class WorkOrderBoardCancelServiceTests 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; diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 9ca211e..5b7d6f9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -374,4 +374,89 @@ public sealed class WorkOrderUpliftServiceTests 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); + } } From 2327097d5eeb2bd87f6e9b52ace567f97a255f55 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 18 Aug 2026 17:41:14 -0300 Subject: [PATCH 7/7] fix(work-orders): symmetric NTE release, terminal guard, serialized cancel (SH-196) Three contract gaps found reviewing the frontend consumer: - Revoking an auto-approved uplift never restored the dispatch NTE. Create raises NTE for both auto-approved and approved requests, but revoke restored it only for Approved, so the allowance was freed while the NTE stayed raised and every create -> auto-approve -> revoke cycle compounded the inflation. Revoke now compensates for NoApprovalRequired symmetrically. - Revoke and cancel had no work-order lifecycle check, so a direct API call could still mutate uplifts on a Completed or Canceled work order; the board dialog's read-only state is UX only. Both now reject terminal work orders in the service. - WorkOrderBoardCancelService read the pending-uplift list outside any gate, so an in-flight create could commit after that read and leave a pending uplift on a Canceled work order. The cancel flow now runs inside the same per-work-order gate as create, so the pending read, withdrawal and status audit serialize against it. --- .../WorkOrderBoardCancelService.cs | 51 +++++---- .../Implementation/WorkOrderUpliftService.cs | 38 ++++++- .../WorkOrderBoardCancelServiceTests.cs | 42 ++++++- .../WorkOrderUpliftServiceTests.cs | 105 ++++++++++++++++++ 4 files changed, 213 insertions(+), 23 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index e847b23..94fe959 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -14,17 +14,20 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderUpliftService _upliftService; + private readonly IUpliftDataService _upliftData; public WorkOrderBoardCancelService( IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, IWorkOrderAuditService auditService, - IWorkOrderUpliftService upliftService) + IWorkOrderUpliftService upliftService, + IUpliftDataService upliftData) { _mutationData = mutationData; _boardService = boardService; _auditService = auditService; _upliftService = upliftService; + _upliftData = upliftData; } public async Task CancelAsync( @@ -32,29 +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 _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, CancellationToken.None); - 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/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 45ff8ea..e36801b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; @@ -170,6 +171,10 @@ namespace SeaHaven.Services.Implementation 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); @@ -236,10 +241,21 @@ namespace SeaHaven.Services.Implementation 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; - if (canonical == UpliftStatus.Approved) + // 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; @@ -348,6 +364,26 @@ namespace SeaHaven.Services.Implementation 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, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 2aae357..d3bd1d9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -26,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, new NoOpUpliftService()); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService(), new PassThroughUpliftData()); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } @@ -205,7 +205,7 @@ public class WorkOrderBoardCancelServiceTests new UserDataService(context), TimeProvider.System, Options.Create(new ApprovalsOptions())); - var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData()); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); @@ -270,7 +270,8 @@ public class WorkOrderBoardCancelServiceTests new ThrowingSaveMutationData(mutationData), boardService, audit, - uplifts); + uplifts, + new PassThroughUpliftData()); await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); @@ -327,6 +328,41 @@ public class WorkOrderBoardCancelServiceTests => 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( diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 9ca211e..67e24d4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -340,6 +340,111 @@ public sealed class WorkOrderUpliftServiceTests 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() {