mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
feat(work-orders): WO-scoped uplift endpoints and board summary (SH-196)
Expose workorders/{id}/uplifts list/create/cancel/revoke for the SH-196 dialog, aggregate upliftSummary on board rows, and add service/controller regression tests.
This commit is contained in:
parent
5f614fe893
commit
b81cfbb005
17 changed files with 1034 additions and 6 deletions
|
|
@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
|
|||
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
||||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 47 routes
|
||||
/// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 51 routes
|
||||
/// come from 49 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
@ -65,6 +65,7 @@ public class WorkOrderRouteContractTests
|
|||
"GET {id:int}/audit",
|
||||
"GET {id:int}/comments",
|
||||
"GET {id:int}/detail",
|
||||
"GET {id:int}/uplifts",
|
||||
"GET {id:int}/media",
|
||||
"PATCH {id:int}/board",
|
||||
"PATCH {id:int}/comments/{commentId:int}",
|
||||
|
|
@ -88,6 +89,9 @@ public class WorkOrderRouteContractTests
|
|||
"POST {id:int}/comments",
|
||||
"POST {id:int}/completion-doc",
|
||||
"POST {id:int}/media",
|
||||
"POST {id:int}/uplifts",
|
||||
"POST {id:int}/uplifts/{upliftId:int}/cancel",
|
||||
"POST {id:int}/uplifts/{upliftId:int}/revoke",
|
||||
"PUT completion-templates/{id:int}",
|
||||
"PUT {id:int}",
|
||||
};
|
||||
|
|
|
|||
115
Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
Normal file
115
Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class WorkOrderUpliftControllerTests
|
||||
{
|
||||
private static WorkOrderDetailController NewController(
|
||||
Mock<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
|
@ -17,13 +19,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
private readonly IWorkOrderDetailService _workOrderDetailService;
|
||||
private readonly IWorkOrderCommentService _workOrderCommentService;
|
||||
private readonly IWorkOrderUpliftService _workOrderUpliftService;
|
||||
private readonly ILogger<WorkOrderDetailController> _logger;
|
||||
|
||||
public WorkOrderDetailController(
|
||||
IWorkOrderDetailService workOrderDetailService,
|
||||
IWorkOrderCommentService workOrderCommentService)
|
||||
IWorkOrderCommentService workOrderCommentService,
|
||||
IWorkOrderUpliftService workOrderUpliftService,
|
||||
ILogger<WorkOrderDetailController> logger)
|
||||
{
|
||||
_workOrderDetailService = workOrderDetailService;
|
||||
_workOrderCommentService = workOrderCommentService;
|
||||
_workOrderUpliftService = workOrderUpliftService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/detail")]
|
||||
|
|
@ -126,5 +134,105 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/uplifts")]
|
||||
public async Task<IActionResult> ListUplifts(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = await _workOrderUpliftService.ListAsync(id, User, cancellationToken);
|
||||
if (items == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(new DataResponse { Status = "Success", Data = new WorkOrderUpliftListDto { Items = items.ToList() } });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/uplifts")]
|
||||
public async Task<IActionResult> CreateUplift(
|
||||
int id,
|
||||
[FromBody] CreateWorkOrderUpliftRequestDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await _workOrderUpliftService.CreateAsync(id, request, User, cancellationToken);
|
||||
if (created == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(new DataResponse { Status = "Success", Data = created });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The uplift request could not be created") });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/uplifts/{upliftId:int}/cancel")]
|
||||
public async Task<IActionResult> CancelUplift(int id, int upliftId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cancelled = await _workOrderUpliftService.CancelAsync(id, upliftId, User, cancellationToken);
|
||||
if (cancelled == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(new DataResponse { Status = "Success", Data = cancelled });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The uplift request cannot be cancelled in its current state") });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/uplifts/{upliftId:int}/revoke")]
|
||||
public async Task<IActionResult> RevokeUplift(
|
||||
int id,
|
||||
int upliftId,
|
||||
[FromBody] RevokeWorkOrderUpliftRequestDto? request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var revoked = await _workOrderUpliftService.RevokeAsync(
|
||||
id,
|
||||
upliftId,
|
||||
request ?? new RevokeWorkOrderUpliftRequestDto(),
|
||||
User,
|
||||
cancellationToken);
|
||||
if (revoked == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(new DataResponse { Status = "Success", Data = revoked });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The uplift request cannot be revoked in its current state") });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ namespace Data.SeaHavenIndustries
|
|||
public string? NotificationError { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftForWorkOrderData : UpliftForDispatchData
|
||||
{
|
||||
public string? CreatedByUserId { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftForDispatchData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
|
|
|||
|
|
@ -81,7 +81,36 @@ namespace SeaHaven.DataServices.Helpers
|
|||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
|
||||
HasUplift = context.DispatchUpliftRequests.Any(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))),
|
||||
PrimaryUpliftStatus = context.DispatchUpliftRequests
|
||||
.Where(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|
||||
.OrderByDescending(u => u.CreatedDate)
|
||||
.Select(u => u.Status)
|
||||
.FirstOrDefault(),
|
||||
PrimaryUpliftAmount = context.DispatchUpliftRequests
|
||||
.Where(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
|
||||
.OrderByDescending(u => u.CreatedDate)
|
||||
.Select(u => (decimal?)u.RequestedNTE)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
|
|
@ -134,6 +163,9 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.RowVersion,
|
||||
w.DispatchRowVersion,
|
||||
w.PendingUpliftCount,
|
||||
w.HasUplift,
|
||||
w.PrimaryUpliftStatus,
|
||||
w.PrimaryUpliftAmount,
|
||||
isUnscheduled);
|
||||
}).ToList();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,64 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
where (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == workOrderId
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId))
|
||||
join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
from dec in decs.DefaultIfEmpty()
|
||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
|
||||
from ev in evidences.DefaultIfEmpty()
|
||||
orderby u.CreatedDate descending
|
||||
select new UpliftForWorkOrderData
|
||||
{
|
||||
Id = u.Id,
|
||||
DispatchId = u.DispatchId,
|
||||
CurrentNTE = u.CurrentNTE,
|
||||
RequestedNTE = u.RequestedNTE,
|
||||
VendorReason = u.VendorReason,
|
||||
Status = u.Status,
|
||||
RequiredTier = u.RequiredTier,
|
||||
RequestedByVendorName = u.RequestedByVendorName,
|
||||
CreatedDate = u.CreatedDate,
|
||||
DecidedAt = u.DecidedAt,
|
||||
DecisionNote = u.DecisionNote,
|
||||
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
||||
DecidedByLastName = dec != null ? dec.LastName : null,
|
||||
EvidenceDocumentId = u.EvidenceDocumentId,
|
||||
ExpiresAt = u.ExpiresAt,
|
||||
NotificationStatus = u.NotificationStatus,
|
||||
NotificationError = u.NotificationError,
|
||||
EvidenceFileName = ev != null ? ev.OriginalFileName : null,
|
||||
EvidenceContentType = ev != null ? ev.ContentType : null,
|
||||
EvidenceSizeBytes = ev != null ? ev.SizeBytes : null,
|
||||
EvidenceScanPassed = ev != null && ev.ScanStatus == "Passed",
|
||||
CreatedByUserId = u.createdby
|
||||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(
|
||||
int requestId,
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.FirstOrDefaultAsync(u =>
|
||||
u.Id == requestId
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == workOrderId
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
byte[]? RowVersion,
|
||||
byte[]? DispatchRowVersion,
|
||||
int PendingUpliftCount,
|
||||
bool HasUplift,
|
||||
string? PrimaryUpliftStatus,
|
||||
decimal? PrimaryUpliftAmount,
|
||||
bool IsUnscheduled);
|
||||
|
||||
public record WorkOrderBoardQueryResult(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ namespace SeaHaven.Services.DTOs
|
|||
public int Total { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderUpliftSummaryDto
|
||||
{
|
||||
public bool HasUplift { get; set; }
|
||||
public int PendingCount { get; set; }
|
||||
public string? PrimaryStatus { get; set; }
|
||||
public decimal? Amount { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardRowDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
|
@ -67,6 +75,8 @@ namespace SeaHaven.Services.DTOs
|
|||
/// Authoritative completion-gate signal for the board/search/detail row contract.
|
||||
/// </summary>
|
||||
public int PendingUpliftCount { get; set; }
|
||||
/// <summary>SH-196: board/search uplift column contract.</summary>
|
||||
public WorkOrderUpliftSummaryDto? UpliftSummary { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardPatchRequestDto
|
||||
|
|
|
|||
32
SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs
Normal file
32
SeaHaven.Services/DTOs/WorkOrderUpliftDTOs.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class WorkOrderUpliftDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public decimal Amount { get; set; }
|
||||
public string Notes { get; set; } = "";
|
||||
public DateTime? RequestedAt { get; set; }
|
||||
public string? RequestedById { get; set; }
|
||||
public string RequestedByName { get; set; } = "";
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string DecidedByName { get; set; } = "";
|
||||
public string DecisionNote { get; set; } = "";
|
||||
}
|
||||
|
||||
public class WorkOrderUpliftListDto
|
||||
{
|
||||
public List<WorkOrderUpliftDto> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateWorkOrderUpliftRequestDto
|
||||
{
|
||||
public decimal Amount { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class RevokeWorkOrderUpliftRequestDto
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
}
|
||||
100
SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs
Normal file
100
SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderUpliftContractMapper
|
||||
{
|
||||
public static WorkOrderUpliftSummaryDto MapSummary(
|
||||
bool hasUplift,
|
||||
int pendingCount,
|
||||
string? primaryStatus,
|
||||
decimal? amount)
|
||||
{
|
||||
var summary = new WorkOrderUpliftSummaryDto
|
||||
{
|
||||
HasUplift = hasUplift || pendingCount > 0,
|
||||
PendingCount = pendingCount,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(primaryStatus))
|
||||
{
|
||||
summary.PrimaryStatus = ToFrontendStatus(primaryStatus);
|
||||
}
|
||||
|
||||
if (amount.HasValue)
|
||||
{
|
||||
summary.Amount = amount.Value;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
public static WorkOrderUpliftSummaryDto MapSummary(WorkOrderBoardRawRow row)
|
||||
=> MapSummary(row.HasUplift, row.PendingUpliftCount, row.PrimaryUpliftStatus, row.PrimaryUpliftAmount);
|
||||
|
||||
public static WorkOrderUpliftDto MapItem(UpliftForWorkOrderData row)
|
||||
{
|
||||
var decidedByName = FormatName(row.DecidedByFirstName, row.DecidedByLastName);
|
||||
return new WorkOrderUpliftDto
|
||||
{
|
||||
Id = row.Id,
|
||||
Status = ToFrontendStatus(row.Status),
|
||||
Amount = row.RequestedNTE,
|
||||
Notes = row.VendorReason ?? "",
|
||||
RequestedAt = row.CreatedDate,
|
||||
RequestedById = row.CreatedByUserId,
|
||||
RequestedByName = row.RequestedByVendorName ?? "",
|
||||
DecidedAt = row.DecidedAt,
|
||||
DecidedByName = decidedByName,
|
||||
DecisionNote = row.DecisionNote ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
public static WorkOrderUpliftDto MapItem(DispatchUpliftRequest request, string? requestedByName, string? decidedByName)
|
||||
=> new()
|
||||
{
|
||||
Id = request.Id,
|
||||
Status = ToFrontendStatus(request.Status),
|
||||
Amount = request.RequestedNTE,
|
||||
Notes = request.VendorReason ?? "",
|
||||
RequestedAt = request.CreatedDate,
|
||||
RequestedById = request.createdby,
|
||||
RequestedByName = requestedByName ?? request.RequestedByVendorName ?? "",
|
||||
DecidedAt = request.DecidedAt,
|
||||
DecidedByName = decidedByName ?? "",
|
||||
DecisionNote = request.DecisionNote ?? "",
|
||||
};
|
||||
|
||||
public static WorkOrderUpliftDto MapRevokedItem(
|
||||
DispatchUpliftRequest request,
|
||||
string? requestedByName,
|
||||
string decidedByName)
|
||||
{
|
||||
var dto = MapItem(request, requestedByName, decidedByName);
|
||||
dto.Status = "revoked";
|
||||
return dto;
|
||||
}
|
||||
|
||||
public static string ToFrontendStatus(string? backendStatus)
|
||||
{
|
||||
return UpliftStatus.ToCanonical(backendStatus) switch
|
||||
{
|
||||
UpliftStatus.Pending => "pending",
|
||||
UpliftStatus.Approved => "approved",
|
||||
UpliftStatus.NoApprovalRequired => "auto_approved",
|
||||
UpliftStatus.Rejected => "rejected",
|
||||
UpliftStatus.Withdrawn => "cancelled",
|
||||
UpliftStatus.Revoked => "revoked",
|
||||
UpliftStatus.ChangesRequested => "pending",
|
||||
UpliftStatus.Expired => "cancelled",
|
||||
_ => "pending",
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatName(string? firstName, string? lastName)
|
||||
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -15,13 +15,14 @@ namespace SeaHaven.Services.Implementation
|
|||
public const string Withdrawn = "Withdrawn";
|
||||
public const string Expired = "Expired";
|
||||
public const string NoApprovalRequired = "NoApprovalRequired";
|
||||
public const string Revoked = "Revoked";
|
||||
|
||||
// Legacy aliases retained only for reads; never written by new code.
|
||||
public const string LegacyDenied = "Denied";
|
||||
public const string LegacyCancelled = "Cancelled";
|
||||
|
||||
private static readonly HashSet<string> TerminalStates =
|
||||
new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired };
|
||||
new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired, Revoked, NoApprovalRequired };
|
||||
|
||||
private static readonly HashSet<string> ActiveStates =
|
||||
new(StringComparer.Ordinal) { Pending, ChangesRequested };
|
||||
|
|
|
|||
|
|
@ -127,7 +127,8 @@ namespace SeaHaven.Services.Implementation
|
|||
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||
RowVersion = row.RowVersion,
|
||||
DispatchRowVersion = row.DispatchRowVersion,
|
||||
PendingUpliftCount = row.PendingUpliftCount
|
||||
PendingUpliftCount = row.PendingUpliftCount,
|
||||
UpliftSummary = WorkOrderUpliftContractMapper.MapSummary(row)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
299
SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
Normal file
299
SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderUpliftService : IWorkOrderUpliftService
|
||||
{
|
||||
private readonly IUpliftDataService _upliftData;
|
||||
private readonly IDispatchDataService _dispatchData;
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly IUserDataService _userData;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
|
||||
public WorkOrderUpliftService(
|
||||
IUpliftDataService upliftData,
|
||||
IDispatchDataService dispatchData,
|
||||
IWorkOrderDetailDataService detailData,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
IUserDataService userData,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ApprovalsOptions> 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 workOrder = await _detailData.GetWorkOrderForMediaAsync(
|
||||
workOrderId,
|
||||
cancellationToken,
|
||||
_accountResolver.ResolveAccountFilter(user));
|
||||
if (workOrder?.PrimaryDispatchId is not int dispatchId)
|
||||
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(dispatchId);
|
||||
if (dispatch == null)
|
||||
throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (IsTerminalForUplift(dispatch.Status))
|
||||
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
|
||||
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var requesterName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var current = dispatch.NTEAmount ?? 0m;
|
||||
var notes = request.Notes?.Trim() ?? "";
|
||||
|
||||
if (request.Amount <= current)
|
||||
{
|
||||
var autoApproved = new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchId,
|
||||
CurrentNTE = current,
|
||||
RequestedNTE = request.Amount,
|
||||
VendorReason = notes,
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
RequestedByVendorName = requesterName,
|
||||
CreatedDate = now,
|
||||
createdby = userId,
|
||||
NotificationStatus = UpliftNotificationStatus.Sent,
|
||||
};
|
||||
await _upliftData.StageAsync(autoApproved, cancellationToken);
|
||||
await StageAuditAsync(dispatch, workOrderId, userId, current, request.Amount, "uplift_auto_approved", now, cancellationToken);
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return WorkOrderUpliftContractMapper.MapItem(autoApproved, requesterName, null);
|
||||
}
|
||||
|
||||
if (await _upliftData.HasActiveAsync(dispatchId, cancellationToken))
|
||||
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
|
||||
|
||||
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
||||
var delta = request.Amount - current;
|
||||
var requiredTier = delta > tier1Max ? 2 : 1;
|
||||
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
|
||||
|
||||
var pending = new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchId,
|
||||
CurrentNTE = current,
|
||||
RequestedNTE = request.Amount,
|
||||
VendorReason = notes,
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = requiredTier,
|
||||
RequestedByVendorName = requesterName,
|
||||
CreatedDate = now,
|
||||
createdby = userId,
|
||||
ExpiresAt = expiresAt,
|
||||
NotificationStatus = UpliftNotificationStatus.Pending,
|
||||
};
|
||||
await _upliftData.StageAsync(pending, cancellationToken);
|
||||
await StageAuditAsync(dispatch, workOrderId, userId, current, request.Amount, "uplift_requested", now, cancellationToken);
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return WorkOrderUpliftContractMapper.MapItem(pending, requesterName, null);
|
||||
}
|
||||
|
||||
public async Task<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");
|
||||
|
||||
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");
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previous = canonical;
|
||||
|
||||
if (canonical == UpliftStatus.Approved)
|
||||
{
|
||||
var oldNte = dispatch.NTEAmount ?? 0m;
|
||||
dispatch.NTEAmount = req.CurrentNTE ?? oldNte;
|
||||
dispatch.LastModificationTime = now;
|
||||
}
|
||||
|
||||
req.Status = UpliftStatus.Revoked;
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await StageAuditAsync(
|
||||
dispatch,
|
||||
workOrderId,
|
||||
userId,
|
||||
previous,
|
||||
UpliftStatus.Revoked,
|
||||
"uplift_revoke",
|
||||
now,
|
||||
cancellationToken,
|
||||
isStatusTransition: true);
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var decidedByName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
|
||||
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
|
||||
return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName);
|
||||
}
|
||||
|
||||
private async Task<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";
|
||||
}
|
||||
}
|
||||
32
SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs
Normal file
32
SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -218,6 +218,11 @@ public class WorkOrderBoardPendingUpliftTests
|
|||
|
||||
var dto = WorkOrderBoardService.MapRawRow(result.ScheduledRows[0], DateTime.UtcNow);
|
||||
Assert.Equal(1, dto.PendingUpliftCount);
|
||||
Assert.NotNull(dto.UpliftSummary);
|
||||
Assert.True(dto.UpliftSummary!.HasUplift);
|
||||
Assert.Equal(1, dto.UpliftSummary.PendingCount);
|
||||
Assert.Equal("pending", dto.UpliftSummary.PrimaryStatus);
|
||||
Assert.Equal(1500m, dto.UpliftSummary.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
221
SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
Normal file
221
SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class WorkOrderUpliftServiceTests
|
||||
{
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<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)
|
||||
{
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "dispatcher-1",
|
||||
UserName = "dispatcher-1",
|
||||
FirstName = "Alex",
|
||||
LastName = "Dispatcher",
|
||||
});
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
VendorId = 1,
|
||||
WorkOrderId = 1,
|
||||
NTEAmount = 1000m,
|
||||
DispatchNumber = "DIS-10",
|
||||
Status = "Completed",
|
||||
});
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "10000000001",
|
||||
PrimaryDispatchId = 10,
|
||||
AccountId = 1,
|
||||
};
|
||||
context.workOrders.Add(workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
return (workOrder, context.Dispatches.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsWorkOrderScopedUplifts()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
Id = 100,
|
||||
DispatchId = dispatch.Id,
|
||||
RequestedNTE = 1500m,
|
||||
Status = "Pending",
|
||||
RequiredTier = 1,
|
||||
NotificationStatus = "Pending",
|
||||
VendorReason = "Extra coil work",
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = NewService(context);
|
||||
var items = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(items);
|
||||
var uplift = Assert.Single(items!);
|
||||
Assert.Equal("pending", uplift.Status);
|
||||
Assert.Equal(1500m, uplift.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AboveCurrentNte_CreatesPendingRequest()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||
var service = NewService(context);
|
||||
|
||||
var created = await service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 1800m, Notes = "Parts" },
|
||||
Dispatcher(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("pending", created!.Status);
|
||||
Assert.Equal("dispatcher-1", created.RequestedById);
|
||||
Assert.Equal("Pending", Assert.Single(context.DispatchUpliftRequests).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AtOrBelowCurrentNte_CreatesAutoApprovedRequest()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||
var service = NewService(context);
|
||||
|
||||
var created = await service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 900m, Notes = "Within limit" },
|
||||
Dispatcher(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("auto_approved", created!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_PendingRequest_Withdraws()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
Id = 100,
|
||||
DispatchId = dispatch.Id,
|
||||
RequestedNTE = 1500m,
|
||||
Status = "Pending",
|
||||
RequiredTier = 1,
|
||||
NotificationStatus = "Pending",
|
||||
createdby = "dispatcher-1",
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = NewService(context);
|
||||
var cancelled = await service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(cancelled);
|
||||
Assert.Equal("cancelled", cancelled!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeAsync_ApprovedRequiresAdminReason()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
Id = 100,
|
||||
DispatchId = dispatch.Id,
|
||||
RequestedNTE = 1500m,
|
||||
CurrentNTE = 1000m,
|
||||
Status = "Approved",
|
||||
RequiredTier = 1,
|
||||
NotificationStatus = "Sent",
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = NewService(context);
|
||||
await Assert.ThrowsAsync<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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue