mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
Merge remote-tracking branch 'origin/dev' into feature/sh-218-additional-contacts
This commit is contained in:
commit
2222d04fcb
22 changed files with 2041 additions and 25 deletions
|
|
@ -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}",
|
||||
};
|
||||
|
|
|
|||
150
Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
Normal file
150
Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
|
|
@ -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") });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -83,7 +83,36 @@ namespace SeaHaven.DataServices.Helpers
|
|||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
|
||||
HasUplift = context.DispatchUpliftRequests.Any(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
|
||||
PrimaryUpliftStatus = context.DispatchUpliftRequests
|
||||
.Where(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|
||||
.OrderByDescending(u => u.CreatedDate)
|
||||
.Select(u => u.Status)
|
||||
.FirstOrDefault(),
|
||||
PrimaryUpliftAmount = context.DispatchUpliftRequests
|
||||
.Where(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|
||||
.OrderByDescending(u => u.CreatedDate)
|
||||
.Select(u => (decimal?)u.RequestedNTE)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
|
|
@ -138,6 +167,9 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.RowVersion,
|
||||
w.DispatchRowVersion,
|
||||
w.PendingUpliftCount,
|
||||
w.HasUplift,
|
||||
w.PrimaryUpliftStatus,
|
||||
w.PrimaryUpliftAmount,
|
||||
isUnscheduled);
|
||||
}).ToList();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
byte[]? RowVersion,
|
||||
byte[]? DispatchRowVersion,
|
||||
int PendingUpliftCount,
|
||||
bool HasUplift,
|
||||
string? PrimaryUpliftStatus,
|
||||
decimal? PrimaryUpliftAmount,
|
||||
bool IsUnscheduled);
|
||||
|
||||
public record WorkOrderBoardQueryResult(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ namespace SeaHaven.Services.DTOs
|
|||
public int Total { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderUpliftSummaryDto
|
||||
{
|
||||
public bool HasUplift { get; set; }
|
||||
public int PendingCount { get; set; }
|
||||
public string? PrimaryStatus { get; set; }
|
||||
public decimal? Amount { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardRowDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
|
@ -69,6 +77,8 @@ namespace SeaHaven.Services.DTOs
|
|||
/// Authoritative completion-gate signal for the board/search/detail row contract.
|
||||
/// </summary>
|
||||
public int PendingUpliftCount { get; set; }
|
||||
/// <summary>SH-196: board/search uplift column contract.</summary>
|
||||
public WorkOrderUpliftSummaryDto? UpliftSummary { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardPatchRequestDto
|
||||
|
|
|
|||
32
SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs
Normal file
32
SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
19
SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs
Normal file
19
SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
100
SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs
Normal file
100
SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ namespace SeaHaven.Services.Implementation
|
|||
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||
RowVersion = row.RowVersion,
|
||||
DispatchRowVersion = row.DispatchRowVersion,
|
||||
PendingUpliftCount = row.PendingUpliftCount
|
||||
PendingUpliftCount = row.PendingUpliftCount,
|
||||
UpliftSummary = WorkOrderUpliftContractMapper.MapSummary(row)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ namespace SeaHaven.Services.Implementation
|
|||
RowVersion = row.RowVersion,
|
||||
DispatchRowVersion = row.DispatchRowVersion,
|
||||
PendingUpliftCount = row.PendingUpliftCount,
|
||||
UpliftSummary = row.UpliftSummary,
|
||||
Description = extended?.Description,
|
||||
Trade = extended?.Trade,
|
||||
Problem = extended?.Problem,
|
||||
|
|
|
|||
438
SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
Normal file
438
SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
37
SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs
Normal file
37
SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -209,6 +209,11 @@ public class WorkOrderDetailServiceTests
|
|||
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal(1, detail!.Info.PendingUpliftCount);
|
||||
Assert.NotNull(detail.Info.UpliftSummary);
|
||||
Assert.True(detail.Info.UpliftSummary!.HasUplift);
|
||||
Assert.Equal(1, detail.Info.UpliftSummary.PendingCount);
|
||||
Assert.Equal("pending", detail.Info.UpliftSummary.PrimaryStatus);
|
||||
Assert.Equal(1500m, detail.Info.UpliftSummary.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
567
SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
Normal file
567
SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue