Merge branch 'dev' into feat/sh-250-roster-additive-patch

This commit is contained in:
Alexandre Brandizzi 2026-08-19 10:27:57 -03:00 • committed by GitHub
commit ec9b36ce29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2620 additions and 25 deletions

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 47 routes
/// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes).
/// duplication is collapsed here, so this is the distinct action-relative contract. 51 routes
/// come from 49 actions (Editworkorder and GetWorkorderById each bind two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
@ -65,6 +65,7 @@ public class WorkOrderRouteContractTests
"GET {id:int}/audit",
"GET {id:int}/comments",
"GET {id:int}/detail",
"GET {id:int}/uplifts",
"GET {id:int}/media",
"PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}",
@ -88,6 +89,9 @@ public class WorkOrderRouteContractTests
"POST {id:int}/comments",
"POST {id:int}/completion-doc",
"POST {id:int}/media",
"POST {id:int}/uplifts",
"POST {id:int}/uplifts/{upliftId:int}/cancel",
"POST {id:int}/uplifts/{upliftId:int}/revoke",
"PUT completion-templates/{id:int}",
"PUT {id:int}",
};

View file

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

View file

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

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260813160000_SH218_WorkOrderAdditionalContacts")]
public partial class SH218_WorkOrderAdditionalContacts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AdditionalContacts",
table: "workOrders",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AdditionalContacts",
table: "workOrders");
}
}
}

View file

@ -2531,6 +2531,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("AdditionalContacts")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");

View file

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

View file

@ -54,6 +54,9 @@ namespace Data.SeaHavenIndustries
public string? PocPhone { get; set; }
[MaxLength(2000)]
public string? PocNotes { get; set; }
/// <summary>JSON array of additional POC contacts beyond primary (name, phone, notes?).</summary>
[MaxLength(4000)]
public string? AdditionalContacts { get; set; }
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
[MaxLength(2000)]
public string? ExtraServices { get; set; }

View file

@ -67,6 +67,7 @@ namespace SeaHaven.DataServices.Helpers
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.AdditionalContacts,
w.DocStatus,
w.CompletedDate,
w.FlagColor,
@ -82,7 +83,36 @@ namespace SeaHaven.DataServices.Helpers
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
HasUplift = context.DispatchUpliftRequests.Any(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
PrimaryUpliftStatus = context.DispatchUpliftRequests
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
.OrderByDescending(u => u.CreatedDate)
.Select(u => u.Status)
.FirstOrDefault(),
PrimaryUpliftAmount = context.DispatchUpliftRequests
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
.OrderByDescending(u => u.CreatedDate)
.Select(u => (decimal?)u.RequestedNTE)
.FirstOrDefault()
})
.ToListAsync();
@ -129,6 +159,7 @@ namespace SeaHaven.DataServices.Helpers
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.AdditionalContacts,
w.DocStatus,
w.CompletedDate,
w.FlagColor,
@ -136,6 +167,9 @@ namespace SeaHaven.DataServices.Helpers
w.RowVersion,
w.DispatchRowVersion,
w.PendingUpliftCount,
w.HasUplift,
w.PrimaryUpliftStatus,
w.PrimaryUpliftAmount,
isUnscheduled);
}).ToList();
}

View file

@ -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<int, SemaphoreSlim> WorkOrderGates = new();
private readonly ApplicationDbContext _context;
public UpliftDataService(ApplicationDbContext context)
@ -100,6 +102,64 @@ namespace SeaHaven.DataServices.Implementation
}).ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
return await (from u in _context.DispatchUpliftRequests
where (u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == workOrderId
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId))
join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs
from dec in decs.DefaultIfEmpty()
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
from ev in evidences.DefaultIfEmpty()
orderby u.CreatedDate descending
select new UpliftForWorkOrderData
{
Id = u.Id,
DispatchId = u.DispatchId,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
CreatedDate = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
DecidedByFirstName = dec != null ? dec.FirstName : null,
DecidedByLastName = dec != null ? dec.LastName : null,
EvidenceDocumentId = u.EvidenceDocumentId,
ExpiresAt = u.ExpiresAt,
NotificationStatus = u.NotificationStatus,
NotificationError = u.NotificationError,
EvidenceFileName = ev != null ? ev.OriginalFileName : null,
EvidenceContentType = ev != null ? ev.ContentType : null,
EvidenceSizeBytes = ev != null ? ev.SizeBytes : null,
EvidenceScanPassed = ev != null && ev.ScanStatus == "Passed",
CreatedByUserId = u.createdby
}).ToListAsync(cancellationToken);
}
public async Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(
int requestId,
int workOrderId,
CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
.FirstOrDefaultAsync(u =>
u.Id == requestId
&& (u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == workOrderId
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)),
cancellationToken);
}
public async Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return await (from u in _context.DispatchUpliftRequests
@ -179,6 +239,29 @@ namespace SeaHaven.DataServices.Implementation
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
}
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
return ForWorkOrder(workOrderId)
.AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken);
}
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
return ForWorkOrder(workOrderId)
.Where(u => u.Status == "NoApprovalRequired")
.SumAsync(u => u.RequestedNTE, cancellationToken);
}
public Task<List<DispatchUpliftRequest>> 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<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
@ -237,6 +320,17 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(cancellationToken);
}
private IQueryable<DispatchUpliftRequest> ForWorkOrder(int workOrderId)
{
return _context.DispatchUpliftRequests.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == workOrderId
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)));
}
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
{
await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken);
@ -246,5 +340,48 @@ namespace SeaHaven.DataServices.Implementation
{
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<T> ExecuteWorkOrderMutationAsync<T>(
int workOrderId,
Func<CancellationToken, Task<T>> 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);
}
}
}

View file

@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
@ -14,6 +16,9 @@ namespace SeaHaven.DataServices.Interfaces
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
@ -23,5 +28,9 @@ namespace SeaHaven.DataServices.Interfaces
Task<List<DispatchUpliftRequest>> GetDueForEscalationAsync(int count, CancellationToken cancellationToken);
Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
Task<T> ExecuteWorkOrderMutationAsync<T>(
int workOrderId,
Func<CancellationToken, Task<T>> work,
CancellationToken cancellationToken);
}
}

View file

@ -47,6 +47,7 @@ namespace SeaHaven.DataServices.Interfaces
string? Problem,
string? ServiceNotes,
string? ExtraServices,
string? AdditionalContacts,
DocStatus? DocStatus,
DateTime? CompletedDate,
string? FlagColor,
@ -54,6 +55,9 @@ namespace SeaHaven.DataServices.Interfaces
byte[]? RowVersion,
byte[]? DispatchRowVersion,
int PendingUpliftCount,
bool HasUplift,
string? PrimaryUpliftStatus,
decimal? PrimaryUpliftAmount,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(

View file

@ -0,0 +1,4 @@
namespace SeaHaven.Services.DTOs
{
public record WorkOrderAdditionalContactDto(string Name, string Phone, string? Notes);
}

View file

@ -21,6 +21,14 @@ namespace SeaHaven.Services.DTOs
public int Total { get; set; }
}
public class WorkOrderUpliftSummaryDto
{
public bool HasUplift { get; set; }
public int PendingCount { get; set; }
public string? PrimaryStatus { get; set; }
public decimal? Amount { get; set; }
}
public class WorkOrderBoardRowDto
{
public int Id { get; set; }
@ -35,6 +43,7 @@ namespace SeaHaven.Services.DTOs
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
public List<WorkOrderAdditionalContactDto>? AdditionalContacts { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public string? LifecycleStatusLabel { get; set; }
public string? LegacyStatus { get; set; }
@ -68,6 +77,8 @@ namespace SeaHaven.Services.DTOs
/// Authoritative completion-gate signal for the board/search/detail row contract.
/// </summary>
public int PendingUpliftCount { get; set; }
/// <summary>SH-196: board/search uplift column contract.</summary>
public WorkOrderUpliftSummaryDto? UpliftSummary { get; set; }
}
public class WorkOrderBoardPatchRequestDto

View file

@ -46,6 +46,7 @@ namespace SeaHaven.Services.DTOs
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
public List<WorkOrderAdditionalContactDto>? AdditionalContacts { get; set; }
public string? TechPhone { get; set; }
public string? VendorNotes { get; set; }
}

View file

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

View file

@ -0,0 +1,127 @@
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<WorkOrderAdditionalContactDto>? ParseJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
var parsed = JsonSerializer.Deserialize<List<WorkOrderAdditionalContactDto>>(json, JsonOptions);
return parsed is { Count: > 0 } ? parsed : null;
}
catch (JsonException)
{
return null;
}
}
public static string? SerializeForStorage(IReadOnlyList<WorkOrderAdditionalContactDto>? 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<WorkOrderAdditionalContactDto>? parsed;
try
{
parsed = JsonSerializer.Deserialize<List<WorkOrderAdditionalContactDto>>(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<WorkOrderAdditionalContactDto> NormalizeList(IReadOnlyList<WorkOrderAdditionalContactDto>? contacts)
{
var normalized = new List<WorkOrderAdditionalContactDto>();
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 (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(
"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;
}
}
}

View file

@ -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
};

View file

@ -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;
}
}

View file

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

View file

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

View file

@ -13,15 +13,21 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderBoardMutationDataService _mutationData;
private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderUpliftService _upliftService;
private readonly IUpliftDataService _upliftData;
public WorkOrderBoardCancelService(
IWorkOrderBoardMutationDataService mutationData,
IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService)
IWorkOrderAuditService auditService,
IWorkOrderUpliftService upliftService,
IUpliftDataService upliftData)
{
_mutationData = mutationData;
_boardService = boardService;
_auditService = auditService;
_upliftService = upliftService;
_upliftData = upliftData;
}
public async Task<WorkOrderBoardRowDto> CancelAsync(
@ -29,28 +35,39 @@ namespace SeaHaven.Services.Implementation
ClaimsPrincipal user,
string? actorId)
{
var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
// SH-196: run the whole cancel under the same per-work-order gate that uplift
// create uses. Previously the pending-uplift read happened outside any gate, so
// an in-flight create could commit after that read and leave a pending uplift on
// a Canceled work order, breaking "cancelling a WO with a pending uplift cancels
// that uplift in the same action".
await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
async ct =>
{
var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, ct);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
{
var existing = await _boardService.GetBoardRowAsync(workOrderId, user);
return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
// Already canceled: nothing to mutate, the row is returned below.
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
return false;
if (workOrder.LifecycleStatus == LifecycleStatus.Completed)
throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status.");
if (workOrder.LifecycleStatus == LifecycleStatus.Completed)
throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status.");
var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? "";
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? "";
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _mutationData.SaveAsync(CancellationToken.None);
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct);
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _mutationData.SaveAsync(ct);
return true;
},
CancellationToken.None);
var row = await _boardService.GetBoardRowAsync(workOrderId, user);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");

View file

@ -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(),
@ -134,6 +136,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)

View file

@ -122,13 +122,15 @@ 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,
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,
PendingUpliftCount = row.PendingUpliftCount
PendingUpliftCount = row.PendingUpliftCount,
UpliftSummary = WorkOrderUpliftContractMapper.MapSummary(row)
};
}

View file

@ -157,6 +157,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },
WorkOrderBoardFieldNames.AdditionalContacts => new List<FieldChange> { ApplyAdditionalContacts(workOrder, value, auditField) },
WorkOrderBoardFieldNames.FlagColor => new List<FieldChange> { 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<string> NormalizeExtraServiceList(List<string>? extras)
{
var normalized = new List<string>();

View file

@ -103,6 +103,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,
@ -130,6 +131,7 @@ namespace SeaHaven.Services.Implementation
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,
PendingUpliftCount = row.PendingUpliftCount,
UpliftSummary = row.UpliftSummary,
Description = extended?.Description,
Trade = extended?.Trade,
Problem = extended?.Problem,

View file

@ -0,0 +1,438 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderUpliftService : IWorkOrderUpliftService
{
private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IUserDataService _userData;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
public WorkOrderUpliftService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
}
public async Task<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var rows = await _upliftData.GetForWorkOrderAsync(workOrderId, cancellationToken);
return rows.Select(WorkOrderUpliftContractMapper.MapItem).ToList();
}
public async Task<WorkOrderUpliftDto?> CreateAsync(
int workOrderId,
CreateWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
var 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<WorkOrderUpliftDto?> CreateLockedAsync(
int workOrderId,
decimal amount,
string notes,
string? userId,
string requesterName,
int? accountFilter,
CancellationToken cancellationToken)
{
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
workOrderId,
cancellationToken,
accountFilter);
if (workOrder?.PrimaryDispatchId is not int dispatchId)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled)
{
throw new InvalidOperationException(
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
}
var dispatch = await _dispatchData.GetByIdAsync(dispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
if (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken))
throw new InvalidOperationException("An open uplift request already exists for this work order");
var now = _timeProvider.GetUtcNow().UtcDateTime;
var current = dispatch.NTEAmount ?? 0m;
var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken);
var remaining = WorkOrderUpliftAllowance.Remaining(
WorkOrderUpliftAllowance.CapFor(workOrder.WorkOrderType),
consumed);
if (WorkOrderUpliftAllowance.AutoApproves(amount, remaining))
{
return await PersistCreatedAsync(
dispatch,
workOrderId,
userId,
requesterName,
current,
amount,
notes,
UpliftStatus.NoApprovalRequired,
requiredTier: 0,
expiresAt: null,
UpliftNotificationStatus.Sent,
"uplift_auto_approved",
now,
cancellationToken);
}
return await PersistCreatedAsync(
dispatch,
workOrderId,
userId,
requesterName,
current,
amount,
notes,
UpliftStatus.Pending,
requiredTier: 1,
now + _approvalsOptions.EffectiveExpiration,
UpliftNotificationStatus.Pending,
"uplift_requested",
now,
cancellationToken);
}
public async Task<WorkOrderUpliftDto?> CancelAsync(
int workOrderId,
int upliftId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken);
if (req == null)
throw new KeyNotFoundException("Uplift request not found");
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
throw new InvalidOperationException($"Cannot cancel a '{UpliftStatus.ToCanonical(req.Status)}' uplift request");
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
// SH-196: same terminal-lifecycle guard as revoke — the read-only dialog state
// is UX only and does not stop a direct API call.
await EnsureWorkOrderAcceptsUpliftMutationAsync(workOrderId, user, "cancel", cancellationToken);
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = UpliftStatus.ToCanonical(req.Status);
req.Status = UpliftStatus.Withdrawn;
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
userId,
previous,
UpliftStatus.Withdrawn,
"uplift_cancel",
now,
cancellationToken,
isStatusTransition: true);
await _upliftData.SaveChangesAsync(cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(req, requesterName, await ResolveUserDisplayNameAsync(userId, cancellationToken));
}
public async Task<WorkOrderUpliftDto?> RevokeAsync(
int workOrderId,
int upliftId,
RevokeWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken);
if (req == null)
throw new KeyNotFoundException("Uplift request not found");
var canonical = UpliftStatus.ToCanonical(req.Status);
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (canonical == UpliftStatus.NoApprovalRequired)
{
if (string.IsNullOrWhiteSpace(userId)
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
{
throw new UpliftForbiddenException("Only the request owner can revoke an auto-approved uplift");
}
}
else if (canonical == UpliftStatus.Approved)
{
if (!user.IsInRole("Admin"))
throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift");
if (string.IsNullOrWhiteSpace(request.Reason))
throw new InvalidOperationException("A reason is required when revoking an approved uplift");
}
else
{
throw new InvalidOperationException($"Cannot revoke a '{canonical}' uplift request");
}
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
// SH-196: revoking is blocked once the work order is Completed or Canceled.
// The board dialog enforces this in the UI only, so without a service-side
// guard a direct API call could still mutate uplifts on a terminal WO.
await EnsureWorkOrderAcceptsUpliftMutationAsync(workOrderId, user, "revoke", cancellationToken);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = canonical;
// SH-196: revoking must free the work order's uplift capacity again. Create
// raises the dispatch NTE for BOTH auto-approved and approved uplifts, so
// revoke has to compensate symmetrically. Restoring only on Approved left the
// NTE raised while SumAutoApprovedAmountForWorkOrderAsync stopped counting the
// revoked request, so every create -> auto-approve -> revoke cycle compounded
// the inflation and handed back allowance that was never actually released.
if (canonical == UpliftStatus.Approved || canonical == UpliftStatus.NoApprovalRequired)
{
var oldNte = dispatch.NTEAmount ?? 0m;
dispatch.NTEAmount = req.CurrentNTE ?? oldNte;
dispatch.LastModificationTime = now;
}
req.Status = UpliftStatus.Revoked;
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.DecisionNote = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
userId,
previous,
UpliftStatus.Revoked,
"uplift_revoke",
now,
cancellationToken,
isStatusTransition: true);
await _upliftData.SaveChangesAsync(cancellationToken);
var decidedByName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName);
}
public async Task WithdrawPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
CancellationToken cancellationToken)
{
var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken);
if (pending.Count == 0)
return;
var now = _timeProvider.GetUtcNow().UtcDateTime;
foreach (var req in pending)
{
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
continue;
var dispatch = req.Dispatch ?? await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
continue;
var previous = UpliftStatus.ToCanonical(req.Status);
req.Status = UpliftStatus.Withdrawn;
req.DecidedAt = now;
req.DecidedByUserId = actorId;
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
actorId,
previous,
UpliftStatus.Withdrawn,
"uplift_cancel",
now,
cancellationToken,
isStatusTransition: true);
}
}
private async Task<WorkOrderUpliftDto> PersistCreatedAsync(
Dispatch dispatch,
int workOrderId,
string? userId,
string requesterName,
decimal currentNte,
decimal amount,
string notes,
string status,
int requiredTier,
DateTime? expiresAt,
string notificationStatus,
string auditAction,
DateTime now,
CancellationToken cancellationToken)
{
var created = new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
CurrentNTE = currentNte,
RequestedNTE = amount,
VendorReason = notes,
Status = status,
RequiredTier = requiredTier,
RequestedByVendorName = requesterName,
CreatedDate = now,
createdby = userId,
ExpiresAt = expiresAt,
NotificationStatus = notificationStatus,
};
if (status == UpliftStatus.NoApprovalRequired)
{
dispatch.NTEAmount = currentNte + amount;
dispatch.LastModificationTime = now;
}
await _upliftData.StageAsync(created, cancellationToken);
await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null);
}
/// <summary>
/// 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.
/// </summary>
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<bool> HasWorkOrderAccessAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId);
}
private async Task<string> ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(userId))
return "";
var names = await _userData.GetDisplayNamesByIdsAsync(new[] { userId });
return names.TryGetValue(userId, out var name) ? name : "";
}
private async Task StageAuditAsync(
Dispatch dispatch,
int workOrderId,
string? userId,
object oldValue,
object newValue,
string action,
DateTime now,
CancellationToken cancellationToken,
bool isStatusTransition = false)
{
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = isStatusTransition ? oldValue.ToString() : $"${Convert.ToDecimal(oldValue):F2}",
NewValue = isStatusTransition ? newValue.ToString() : $"${Convert.ToDecimal(newValue):F2}",
Action = action,
ActorType = "internal",
CreatedAt = now,
}, cancellationToken);
}
private static bool IsTerminalForUplift(string? status) =>
status is "Verified" or "Cancelled" or "Canceled" or "Refused";
}
}

View file

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

View file

@ -81,6 +81,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(HasNameAndPhoneWhenPresent)
.WithMessage("Each additional contact must include a name and phone.");
})
.When(x => x.AdditionalContacts != null);
}
private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request)
@ -93,5 +111,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;
}
}
}

View file

@ -1,10 +1,15 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
@ -21,7 +26,7 @@ public class WorkOrderBoardCancelServiceTests
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit);
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService(), new PassThroughUpliftData());
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
return (context, cancel, update);
}
@ -149,4 +154,334 @@ public class WorkOrderBoardCancelServiceTests
Assert.Equal("CanceledReadOnly", ex.Code);
}
[Fact]
public async Task Cancel_WithdrawsPendingUpliftAndWritesAudit()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Status = "Incomplete",
PrimaryDispatchId = 10,
AccountId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 600m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var uplifts = new WorkOrderUpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status);
Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "StatusChanged");
}
[Fact]
public async Task Cancel_LateSaveFailure_LeavesPendingUpliftAndOpenWorkOrder()
{
var databaseName = Guid.NewGuid().ToString();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName)
.Options;
await using var context = new ApplicationDbContext(options);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Status = "Incomplete",
PrimaryDispatchId = 10,
AccountId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 600m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var uplifts = new WorkOrderUpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
new ThrowingSaveMutationData(mutationData),
boardService,
audit,
uplifts,
new PassThroughUpliftData());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
await using var verify = new ApplicationDbContext(options);
Assert.Equal(LifecycleStatus.Incomplete, Assert.Single(verify.workOrders).LifecycleStatus);
Assert.Equal("Pending", Assert.Single(verify.DispatchUpliftRequests).Status);
Assert.Empty(verify.WorkOrderAuditLogs);
}
[Fact]
public async Task Cancel_SerializedWithCreate_DoesNotLeavePendingOnCanceledWorkOrder()
{
var databaseName = Guid.NewGuid().ToString();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName)
.Options;
await using (var seed = new ApplicationDbContext(options))
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(seed);
seed.Users.Add(new ApplicationUser
{
Id = "dispatcher-1",
UserName = "dispatcher-1",
FirstName = "Alex",
LastName = "Dispatcher",
});
seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
seed.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
NTEAmount = 1000m,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
seed.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
PrimaryDispatchId = 10,
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
LifecycleStatus = LifecycleStatus.Incomplete,
Status = "Incomplete",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 },
});
await seed.SaveChangesAsync();
}
await using var createContext = new ApplicationDbContext(options);
await using var cancelContext = new ApplicationDbContext(options);
var createService = new WorkOrderUpliftService(
new UpliftDataService(createContext),
new DispatchDataService(createContext),
new WorkOrderDetailDataService(createContext),
WorkOrderAccountTestHelpers.Resolver(createContext),
new UserDataService(createContext),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var boardData = new WorkOrderBoardDataService(cancelContext);
var mutationData = new WorkOrderBoardMutationDataService(cancelContext);
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext));
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(cancelContext));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(cancelContext), fieldLocks);
var cancelUplifts = new WorkOrderUpliftService(
new UpliftDataService(cancelContext),
new DispatchDataService(cancelContext),
new WorkOrderDetailDataService(cancelContext),
WorkOrderAccountTestHelpers.Resolver(cancelContext),
new UserDataService(cancelContext),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
mutationData,
boardService,
audit,
cancelUplifts,
new UpliftDataService(cancelContext));
await Task.WhenAll(
createService.CreateAsync(
1,
new CreateWorkOrderUpliftRequestDto { Amount = 501m, Notes = "Pending" },
WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1"),
CancellationToken.None),
cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
await using var verify = new ApplicationDbContext(options);
var workOrder = Assert.Single(verify.workOrders);
Assert.Equal(LifecycleStatus.Canceled, workOrder.LifecycleStatus);
Assert.DoesNotContain(verify.DispatchUpliftRequests, row => row.Status == "Pending");
}
private sealed class ThrowingSaveMutationData : IWorkOrderBoardMutationDataService
{
private readonly IWorkOrderBoardMutationDataService _inner;
public ThrowingSaveMutationData(IWorkOrderBoardMutationDataService inner)
{
_inner = inner;
}
public Task<bool> VendorExistsAsync(int vendorId, CancellationToken cancellationToken)
=> _inner.VendorExistsAsync(vendorId, cancellationToken);
public Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken)
=> _inner.GetMaxWorkOrderIdAsync(cancellationToken);
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _inner.GetTrackedWorkOrderAsync(workOrderId, cancellationToken);
public Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken)
=> _inner.GetTrackedDispatchAsync(dispatchId, workOrderId, cancellationToken);
public Task<Dispatch?> 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<CancellationToken, Task> work, CancellationToken cancellationToken)
=> _inner.ExecuteTransactionalAsync(work, cancellationToken);
public Task<BoardSaveOutcome> SaveAsync(CancellationToken cancellationToken)
=> throw new InvalidOperationException("forced late failure");
}
/// <summary>
/// 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.
/// </summary>
private sealed class PassThroughUpliftData : IUpliftDataService
{
public Task<T> ExecuteWorkOrderMutationAsync<T>(
int workOrderId,
Func<CancellationToken, Task<T>> work,
CancellationToken cancellationToken) => work(cancellationToken);
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
throw new NotSupportedException();
public Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<DispatchUpliftRequest>> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<DispatchUpliftRequest>> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<DispatchUpliftRequest>> 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<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
int workOrderId,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<WorkOrderUpliftDto>?>(Array.Empty<WorkOrderUpliftDto>());
public Task<WorkOrderUpliftDto?> CreateAsync(
int workOrderId,
CreateWorkOrderUpliftRequestDto request,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<WorkOrderUpliftDto?>(null);
public Task<WorkOrderUpliftDto?> CancelAsync(
int workOrderId,
int upliftId,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<WorkOrderUpliftDto?>(null);
public Task<WorkOrderUpliftDto?> RevokeAsync(
int workOrderId,
int upliftId,
RevokeWorkOrderUpliftRequestDto request,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<WorkOrderUpliftDto?>(null);
public Task WithdrawPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
CancellationToken cancellationToken) => Task.CompletedTask;
}
}

View file

@ -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;
@ -424,4 +426,142 @@ public class WorkOrderBoardCreateServiceTests
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
}
[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<WorkOrderAdditionalContactDto>
{
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);
}
[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<WorkOrderAdditionalContactDto>()
}, 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<WorkOrderAdditionalContactDto>
{
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<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
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<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
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<WorkOrderAdditionalContactDto>
{
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);
}
}

View file

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

View file

@ -502,6 +502,154 @@ 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<WorkOrderBoardValidationException>(() =>
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_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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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()
{

View file

@ -209,6 +209,76 @@ 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]
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]

View file

@ -0,0 +1,567 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace SeaHavenIndustries.Tests;
public sealed class WorkOrderUpliftServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ApprovalsOptions NewOptions() => new()
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
};
private static WorkOrderUpliftService NewService(ApplicationDbContext context)
{
return new WorkOrderUpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
TimeProvider.System,
Options.Create(NewOptions()));
}
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
ApplicationDbContext context,
WorkOrderType type = WorkOrderType.PM)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Users.Add(new ApplicationUser
{
Id = "dispatcher-1",
UserName = "dispatcher-1",
FirstName = "Alex",
LastName = "Dispatcher",
});
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
NTEAmount = 1000m,
DispatchNumber = "DIS-10",
Status = "Completed",
});
var workOrder = new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
PrimaryDispatchId = 10,
AccountId = 1,
WorkOrderType = type,
};
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
return (workOrder, context.Dispatches.Single(d => d.Id == 10));
}
[Fact]
public async Task ListAsync_ReturnsWorkOrderScopedUplifts()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
VendorReason = "Extra coil work",
});
await context.SaveChangesAsync();
var service = NewService(context);
var items = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None);
Assert.NotNull(items);
var uplift = Assert.Single(items!);
Assert.Equal("pending", uplift.Status);
Assert.Equal(1500m, uplift.Amount);
}
[Fact]
public async Task CreateAsync_PmAmountAtCap_CreatesAutoApprovedRequest()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
Dispatcher(),
CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("auto_approved", created!.Status);
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
}
[Fact]
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 501m, Notes = "Parts" },
Dispatcher(),
CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("pending", created!.Status);
Assert.Equal("dispatcher-1", created.RequestedById);
Assert.Equal("Pending", Assert.Single(context.DispatchUpliftRequests).Status);
}
[Fact]
public async Task CreateAsync_PmSecondRequestExceedsRemainingAllowance_CreatesPending()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var first = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "First" },
Dispatcher(),
CancellationToken.None);
Assert.Equal("auto_approved", first!.Status);
var second = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 200m, Notes = "Second" },
Dispatcher(),
CancellationToken.None);
Assert.Equal("pending", second!.Status);
}
[Fact]
public async Task CreateAsync_EmergencyAtCap_CreatesAutoApprovedRequest()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 5000m, Notes = "Emergency parts" },
Dispatcher(),
CancellationToken.None);
Assert.Equal("auto_approved", created!.Status);
}
[Fact]
public async Task CreateAsync_EmergencyAboveCap_CreatesPendingRequest()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 5001m, Notes = "Above emergency cap" },
Dispatcher(),
CancellationToken.None);
Assert.Equal("pending", created!.Status);
}
[Fact]
public async Task CreateAsync_PendingOnOtherDispatch_BlocksNewRequest()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
context.Dispatches.Add(new Dispatch
{
Id = 11,
VendorId = 1,
WorkOrderId = workOrder.Id,
NTEAmount = 800m,
DispatchNumber = "DIS-11",
Status = "Scheduled",
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 11,
RequestedNTE = 300m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
});
await context.SaveChangesAsync();
var service = NewService(context);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 100m, Notes = "Second open" },
Dispatcher(),
CancellationToken.None));
Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task WithdrawPendingForWorkOrderAsync_CancelsOpenRequestsAndAudits()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 600m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
createdby = "dispatcher-1",
});
await context.SaveChangesAsync();
var service = NewService(context);
await service.WithdrawPendingForWorkOrderAsync(workOrder.Id, "actor-1", CancellationToken.None);
await context.SaveChangesAsync();
Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status);
Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
}
[Fact]
public async Task CancelAsync_PendingRequest_Withdraws()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
createdby = "dispatcher-1",
});
await context.SaveChangesAsync();
var service = NewService(context);
var cancelled = await service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None);
Assert.NotNull(cancelled);
Assert.Equal("cancelled", cancelled!.Status);
}
[Fact]
public async Task RevokeAsync_ApprovedRequiresAdminReason()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
CurrentNTE = 1000m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
});
await context.SaveChangesAsync();
var service = NewService(context);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.RevokeAsync(
workOrder.Id,
100,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher("dispatcher-1"),
CancellationToken.None));
}
[Fact]
public async Task RevokeAsync_AdminRevokesApproved_ReturnsRevokedStatus()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
dispatch.NTEAmount = 1500m;
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 1500m,
CurrentNTE = 1000m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
});
await context.SaveChangesAsync();
var service = NewService(context);
var revoked = await service.RevokeAsync(
workOrder.Id,
100,
new RevokeWorkOrderUpliftRequestDto { Reason = "Policy change" },
WorkOrderAccountTestHelpers.OrgWideAdmin("admin-1"),
CancellationToken.None);
Assert.NotNull(revoked);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[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<InvalidOperationException>(() => 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<InvalidOperationException>(() => service.CancelAsync(
workOrder.Id,
101,
Dispatcher(),
CancellationToken.None));
Assert.Equal("Pending", context.DispatchUpliftRequests.Single(r => r.Id == 101).Status);
}
[Fact]
public async Task CreateAsync_ConcurrentRequests_PreserveOnePendingAndCap()
{
var databaseName = Guid.NewGuid().ToString();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName)
.Options;
await using (var seed = new ApplicationDbContext(options))
{
await SeedWorkOrderAsync(seed);
}
await using var firstContext = new ApplicationDbContext(options);
await using var secondContext = new ApplicationDbContext(options);
var first = NewService(firstContext);
var second = NewService(secondContext);
var request = new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Concurrent" };
var results = await Task.WhenAll(
first.CreateAsync(1, request, Dispatcher(), CancellationToken.None),
second.CreateAsync(1, request, Dispatcher(), CancellationToken.None));
await using var verify = new ApplicationDbContext(options);
var rows = verify.DispatchUpliftRequests.ToList();
var autoApproved = rows.Where(row => row.Status == "NoApprovalRequired").ToList();
var pending = rows.Where(row => row.Status == "Pending").ToList();
Assert.Equal(2, results.Length);
Assert.True(results.All(result => result != null));
Assert.Single(autoApproved);
Assert.Single(pending);
Assert.True(autoApproved.Sum(row => row.RequestedNTE) <= 500m);
}
[Fact]
public async Task RevokeAsync_OwnerRevokesAutoApproved_RestoresNteAndAllowance()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
Dispatcher(),
CancellationToken.None);
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
var revoked = await service.RevokeAsync(
workOrder.Id,
created!.Id,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher(),
CancellationToken.None);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
Assert.Equal(0m, await new UpliftDataService(context)
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
}
[Theory]
[InlineData(LifecycleStatus.Completed)]
[InlineData(LifecycleStatus.Canceled)]
public async Task RevokeAsync_TerminalWorkOrder_Throws(LifecycleStatus status)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
workOrder.LifecycleStatus = status;
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = dispatch.Id,
RequestedNTE = 400m,
CurrentNTE = 1000m,
Status = "NoApprovalRequired",
RequiredTier = 0,
NotificationStatus = "Sent",
createdby = "dispatcher-1",
});
await context.SaveChangesAsync();
var service = NewService(context);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None));
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
}
}