mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 15:03:29 +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
|
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
||||||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
/// 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
|
/// 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
|
/// duplication is collapsed here, so this is the distinct action-relative contract. 51 routes
|
||||||
/// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes).
|
/// come from 49 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
|
@ -65,6 +65,7 @@ public class WorkOrderRouteContractTests
|
||||||
"GET {id:int}/audit",
|
"GET {id:int}/audit",
|
||||||
"GET {id:int}/comments",
|
"GET {id:int}/comments",
|
||||||
"GET {id:int}/detail",
|
"GET {id:int}/detail",
|
||||||
|
"GET {id:int}/uplifts",
|
||||||
"GET {id:int}/media",
|
"GET {id:int}/media",
|
||||||
"PATCH {id:int}/board",
|
"PATCH {id:int}/board",
|
||||||
"PATCH {id:int}/comments/{commentId:int}",
|
"PATCH {id:int}/comments/{commentId:int}",
|
||||||
|
|
@ -88,6 +89,9 @@ public class WorkOrderRouteContractTests
|
||||||
"POST {id:int}/comments",
|
"POST {id:int}/comments",
|
||||||
"POST {id:int}/completion-doc",
|
"POST {id:int}/completion-doc",
|
||||||
"POST {id:int}/media",
|
"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 completion-templates/{id:int}",
|
||||||
"PUT {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.DTOs;
|
||||||
|
using Api.SeaHavenIndustries.Helper;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
@ -17,13 +19,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
private readonly IWorkOrderDetailService _workOrderDetailService;
|
private readonly IWorkOrderDetailService _workOrderDetailService;
|
||||||
private readonly IWorkOrderCommentService _workOrderCommentService;
|
private readonly IWorkOrderCommentService _workOrderCommentService;
|
||||||
|
private readonly IWorkOrderUpliftService _workOrderUpliftService;
|
||||||
|
private readonly ILogger<WorkOrderDetailController> _logger;
|
||||||
|
|
||||||
public WorkOrderDetailController(
|
public WorkOrderDetailController(
|
||||||
IWorkOrderDetailService workOrderDetailService,
|
IWorkOrderDetailService workOrderDetailService,
|
||||||
IWorkOrderCommentService workOrderCommentService)
|
IWorkOrderCommentService workOrderCommentService,
|
||||||
|
IWorkOrderUpliftService workOrderUpliftService,
|
||||||
|
ILogger<WorkOrderDetailController> logger)
|
||||||
{
|
{
|
||||||
_workOrderDetailService = workOrderDetailService;
|
_workOrderDetailService = workOrderDetailService;
|
||||||
_workOrderCommentService = workOrderCommentService;
|
_workOrderCommentService = workOrderCommentService;
|
||||||
|
_workOrderUpliftService = workOrderUpliftService;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/detail")]
|
[HttpGet("{id:int}/detail")]
|
||||||
|
|
@ -126,5 +134,105 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
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 string? NotificationError { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UpliftForWorkOrderData : UpliftForDispatchData
|
||||||
|
{
|
||||||
|
public string? CreatedByUserId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class UpliftForDispatchData
|
public class UpliftForDispatchData
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,36 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||||
&& (
|
&& (
|
||||||
u.Dispatch.WorkOrderId == w.Id
|
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();
|
.ToListAsync();
|
||||||
|
|
||||||
|
|
@ -138,6 +167,9 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.RowVersion,
|
w.RowVersion,
|
||||||
w.DispatchRowVersion,
|
w.DispatchRowVersion,
|
||||||
w.PendingUpliftCount,
|
w.PendingUpliftCount,
|
||||||
|
w.HasUplift,
|
||||||
|
w.PrimaryUpliftStatus,
|
||||||
|
w.PrimaryUpliftAmount,
|
||||||
isUnscheduled);
|
isUnscheduled);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
@ -6,6 +7,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
public class UpliftDataService : IUpliftDataService
|
public class UpliftDataService : IUpliftDataService
|
||||||
{
|
{
|
||||||
|
private static readonly ConcurrentDictionary<int, SemaphoreSlim> WorkOrderGates = new();
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
public UpliftDataService(ApplicationDbContext context)
|
public UpliftDataService(ApplicationDbContext context)
|
||||||
|
|
@ -100,6 +102,64 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}).ToListAsync(cancellationToken);
|
}).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)
|
public async Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await (from u in _context.DispatchUpliftRequests
|
return await (from u in _context.DispatchUpliftRequests
|
||||||
|
|
@ -179,6 +239,29 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
|
&& (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)
|
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.DispatchUpliftRequests
|
return await _context.DispatchUpliftRequests
|
||||||
|
|
@ -237,6 +320,17 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.ToListAsync(cancellationToken);
|
.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)
|
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken);
|
await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken);
|
||||||
|
|
@ -246,5 +340,48 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
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(
|
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, 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<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, 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.
|
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
||||||
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
||||||
Task<bool> HasPendingAsync(int dispatchId, 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).
|
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetActiveRequestAsync(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<List<DispatchUpliftRequest>> GetDueForEscalationAsync(int count, CancellationToken cancellationToken);
|
||||||
Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken);
|
Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken);
|
||||||
Task SaveChangesAsync(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[]? RowVersion,
|
||||||
byte[]? DispatchRowVersion,
|
byte[]? DispatchRowVersion,
|
||||||
int PendingUpliftCount,
|
int PendingUpliftCount,
|
||||||
|
bool HasUplift,
|
||||||
|
string? PrimaryUpliftStatus,
|
||||||
|
decimal? PrimaryUpliftAmount,
|
||||||
bool IsUnscheduled);
|
bool IsUnscheduled);
|
||||||
|
|
||||||
public record WorkOrderBoardQueryResult(
|
public record WorkOrderBoardQueryResult(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,14 @@ namespace SeaHaven.Services.DTOs
|
||||||
public int Total { get; set; }
|
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 class WorkOrderBoardRowDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
@ -69,6 +77,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
/// Authoritative completion-gate signal for the board/search/detail row contract.
|
/// Authoritative completion-gate signal for the board/search/detail row contract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PendingUpliftCount { get; set; }
|
public int PendingUpliftCount { get; set; }
|
||||||
|
/// <summary>SH-196: board/search uplift column contract.</summary>
|
||||||
|
public WorkOrderUpliftSummaryDto? UpliftSummary { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WorkOrderBoardPatchRequestDto
|
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 Withdrawn = "Withdrawn";
|
||||||
public const string Expired = "Expired";
|
public const string Expired = "Expired";
|
||||||
public const string NoApprovalRequired = "NoApprovalRequired";
|
public const string NoApprovalRequired = "NoApprovalRequired";
|
||||||
|
public const string Revoked = "Revoked";
|
||||||
|
|
||||||
// Legacy aliases retained only for reads; never written by new code.
|
// Legacy aliases retained only for reads; never written by new code.
|
||||||
public const string LegacyDenied = "Denied";
|
public const string LegacyDenied = "Denied";
|
||||||
public const string LegacyCancelled = "Cancelled";
|
public const string LegacyCancelled = "Cancelled";
|
||||||
|
|
||||||
private static readonly HashSet<string> TerminalStates =
|
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 =
|
private static readonly HashSet<string> ActiveStates =
|
||||||
new(StringComparer.Ordinal) { Pending, ChangesRequested };
|
new(StringComparer.Ordinal) { Pending, ChangesRequested };
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,21 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||||
private readonly IWorkOrderBoardService _boardService;
|
private readonly IWorkOrderBoardService _boardService;
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
private readonly IWorkOrderUpliftService _upliftService;
|
||||||
|
private readonly IUpliftDataService _upliftData;
|
||||||
|
|
||||||
public WorkOrderBoardCancelService(
|
public WorkOrderBoardCancelService(
|
||||||
IWorkOrderBoardMutationDataService mutationData,
|
IWorkOrderBoardMutationDataService mutationData,
|
||||||
IWorkOrderBoardService boardService,
|
IWorkOrderBoardService boardService,
|
||||||
IWorkOrderAuditService auditService)
|
IWorkOrderAuditService auditService,
|
||||||
|
IWorkOrderUpliftService upliftService,
|
||||||
|
IUpliftDataService upliftData)
|
||||||
{
|
{
|
||||||
_mutationData = mutationData;
|
_mutationData = mutationData;
|
||||||
_boardService = boardService;
|
_boardService = boardService;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
|
_upliftService = upliftService;
|
||||||
|
_upliftData = upliftData;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderBoardRowDto> CancelAsync(
|
public async Task<WorkOrderBoardRowDto> CancelAsync(
|
||||||
|
|
@ -29,28 +35,39 @@ namespace SeaHaven.Services.Implementation
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string? actorId)
|
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)
|
if (workOrder == null)
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
|
// Already canceled: nothing to mutate, the row is returned below.
|
||||||
{
|
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
|
||||||
var existing = await _boardService.GetBoardRowAsync(workOrderId, user);
|
return false;
|
||||||
return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workOrder.LifecycleStatus == LifecycleStatus.Completed)
|
if (workOrder.LifecycleStatus == LifecycleStatus.Completed)
|
||||||
throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status.");
|
throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status.");
|
||||||
|
|
||||||
var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? "";
|
var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? "";
|
||||||
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
|
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
|
||||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled);
|
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled);
|
||||||
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||||
workOrder.LegacyStatus = workOrder.Status;
|
workOrder.LegacyStatus = workOrder.Status;
|
||||||
|
|
||||||
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct);
|
||||||
await _mutationData.SaveAsync(CancellationToken.None);
|
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
||||||
|
await _mutationData.SaveAsync(ct);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
var row = await _boardService.GetBoardRowAsync(workOrderId, user);
|
var row = await _boardService.GetBoardRowAsync(workOrderId, user);
|
||||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||||
RowVersion = row.RowVersion,
|
RowVersion = row.RowVersion,
|
||||||
DispatchRowVersion = row.DispatchRowVersion,
|
DispatchRowVersion = row.DispatchRowVersion,
|
||||||
PendingUpliftCount = row.PendingUpliftCount
|
PendingUpliftCount = row.PendingUpliftCount,
|
||||||
|
UpliftSummary = WorkOrderUpliftContractMapper.MapSummary(row)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
RowVersion = row.RowVersion,
|
RowVersion = row.RowVersion,
|
||||||
DispatchRowVersion = row.DispatchRowVersion,
|
DispatchRowVersion = row.DispatchRowVersion,
|
||||||
PendingUpliftCount = row.PendingUpliftCount,
|
PendingUpliftCount = row.PendingUpliftCount,
|
||||||
|
UpliftSummary = row.UpliftSummary,
|
||||||
Description = extended?.Description,
|
Description = extended?.Description,
|
||||||
Trade = extended?.Trade,
|
Trade = extended?.Trade,
|
||||||
Problem = extended?.Problem,
|
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;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHavenIndustries.Tests;
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
|
@ -21,7 +26,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
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);
|
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
||||||
return (context, cancel, update);
|
return (context, cancel, update);
|
||||||
}
|
}
|
||||||
|
|
@ -149,4 +154,334 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
|
|
||||||
Assert.Equal("CanceledReadOnly", ex.Code);
|
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);
|
var dto = WorkOrderBoardService.MapRawRow(result.ScheduledRows[0], DateTime.UtcNow);
|
||||||
Assert.Equal(1, dto.PendingUpliftCount);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,11 @@ public class WorkOrderDetailServiceTests
|
||||||
|
|
||||||
Assert.NotNull(detail);
|
Assert.NotNull(detail);
|
||||||
Assert.Equal(1, detail!.Info.PendingUpliftCount);
|
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]
|
[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