mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
fix(uplifts): resolve work-order uplifts through owned dispatches (SH-393)
Board create left the new primary dispatch with no WorkOrderId, so uplifts created on those work orders were written to a dispatch the work order's uplift reads never resolve: not listed, allowance never consumed, queue WO number blank. The same orphan made ApptDate/vendor patches fail with "A primary dispatch is required". - Board create backfills Dispatch.WorkOrderId after the first save. - Uplift create resolves its dispatch through the read-side scope (non-deleted, owned or linked); otherwise the stable "no primary dispatch" error. - Data-only migration assigns existing orphaned primaries to the single work order naming them primary; idempotent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f7db6b9f84
commit
89376e99e4
9 changed files with 4656 additions and 22 deletions
4213
Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs
generated
Normal file
4213
Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,43 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SH-393: board-created work orders with a vendor stored their primary dispatch with
|
||||||
|
/// no WorkOrderId and no DispatchWorkOrders link, so every owned-or-linked read (uplift
|
||||||
|
/// list, allowance, approval queue WO number, dispatch patches) missed it. Data-only
|
||||||
|
/// heal: assign the dispatch to the single work order that names it as primary. Rows
|
||||||
|
/// already owned or linked, and dispatches named primary by more than one work order,
|
||||||
|
/// are left untouched, so re-running is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SH393_BackfillBoardCreatedDispatchWorkOrder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE d
|
||||||
|
SET d.[WorkOrderId] = w.[Id]
|
||||||
|
FROM [Dispatches] AS d
|
||||||
|
INNER JOIN [workOrders] AS w ON w.[PrimaryDispatchId] = d.[Id]
|
||||||
|
WHERE d.[WorkOrderId] IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM [DispatchWorkOrders] AS l
|
||||||
|
WHERE l.[DispatchId] = d.[Id])
|
||||||
|
AND (
|
||||||
|
SELECT COUNT(*) FROM [workOrders] AS owner
|
||||||
|
WHERE owner.[PrimaryDispatchId] = d.[Id]) = 1;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// The heal is not reversible: healed rows are indistinguishable from dispatches
|
||||||
|
// that were created with their work order set.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -336,6 +336,25 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken);
|
.AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int? primaryDispatchId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = await _context.Dispatches
|
||||||
|
.Where(d =>
|
||||||
|
(d.IsDeleted == null || d.IsDeleted == false)
|
||||||
|
&& (
|
||||||
|
d.WorkOrderId == workOrderId
|
||||||
|
|| d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)))
|
||||||
|
.OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate)
|
||||||
|
.ThenByDescending(d => d.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates.FirstOrDefault(d => d.Id == primaryDispatchId)
|
||||||
|
?? candidates.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return ForWorkOrder(workOrderId)
|
return ForWorkOrder(workOrderId)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
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<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
|
// SH-393: the tracked dispatch a work-order uplift is written to, resolved through the
|
||||||
|
// same scope the work-order uplift reads use (non-deleted, owned or linked): the
|
||||||
|
// work order's primary dispatch when it qualifies, otherwise its latest qualifying one.
|
||||||
|
Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken);
|
||||||
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
// Approval queue read contract: set-based per-work-order exposure totals covering
|
// Approval queue read contract: set-based per-work-order exposure totals covering
|
||||||
// auto-approved and admin-approved uplift amounts (pending, rejected,
|
// auto-approved and admin-approved uplift amounts (pending, rejected,
|
||||||
|
|
|
||||||
|
|
@ -249,10 +249,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
_mutationData.TrackNewWorkOrder(workOrder);
|
_mutationData.TrackNewWorkOrder(workOrder);
|
||||||
var firstOutcome = await _mutationData.SaveAsync(ct);
|
var firstOutcome = await _mutationData.SaveAsync(ct);
|
||||||
|
|
||||||
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
// SH-393: the first save already fixes up PrimaryDispatchId from the
|
||||||
|
// PrimaryDispatch navigation, so the dispatch's own WorkOrderId must be
|
||||||
|
// backfilled independently. Without it the dispatch belongs to no work order
|
||||||
|
// and every owned-or-linked read (uplifts, dispatch patches) misses it.
|
||||||
|
if (dispatch != null)
|
||||||
{
|
{
|
||||||
workOrder.PrimaryDispatchId = dispatch.Id;
|
workOrder.PrimaryDispatchId ??= dispatch.Id;
|
||||||
dispatch.WorkOrderId = workOrder.Id;
|
dispatch.WorkOrderId ??= workOrder.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,24 +104,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
|
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
|
||||||
}
|
}
|
||||||
|
|
||||||
Dispatch? dispatch;
|
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
|
||||||
if (workOrder.PrimaryDispatchId is int primaryDispatchId)
|
// Loading the primary by id alone accepted soft-deleted, unlinked, or other work
|
||||||
{
|
// orders' dispatches, so the request never showed on this work order and never
|
||||||
dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId);
|
// consumed its allowance.
|
||||||
if (dispatch == null)
|
var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync(
|
||||||
throw new KeyNotFoundException("Dispatch not found");
|
workOrderId,
|
||||||
}
|
workOrder.PrimaryDispatchId,
|
||||||
else
|
cancellationToken);
|
||||||
{
|
if (dispatch == null)
|
||||||
dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken))
|
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
||||||
.Where(candidate => candidate.IsDeleted != true)
|
|
||||||
.OrderByDescending(candidate => candidate.DispatchedAt ?? candidate.CreatedDate)
|
|
||||||
.ThenByDescending(candidate => candidate.Id)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (dispatch == null)
|
|
||||||
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsTerminalForUplift(dispatch.Status))
|
if (IsTerminalForUplift(dispatch.Status))
|
||||||
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
|
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, 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> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
public Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SH-393: an uplift created from a work order must land on a dispatch the work order's
|
||||||
|
/// uplift reads resolve back to it (non-deleted, owned or linked), so the request is
|
||||||
|
/// listed on that work order, consumes its allowance and carries its WO number in the
|
||||||
|
/// approval queue.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkOrderUpliftDispatchOwnershipTests
|
||||||
|
{
|
||||||
|
private const string NoPrimaryDispatchMessage = "Work order has no primary dispatch for uplift requests";
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||||
|
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
|
||||||
|
context.SaveChanges();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderBoardCreateService NewBoardCreateService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
return new WorkOrderBoardCreateService(
|
||||||
|
boardData,
|
||||||
|
new WorkOrderBoardMutationDataService(context),
|
||||||
|
new WorkOrderBoardService(boardData, resolver),
|
||||||
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
|
||||||
|
new WorkOrderBoardCreateValidation(),
|
||||||
|
resolver,
|
||||||
|
new WorkOrderPocDataService(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
return new WorkOrderBoardUpdateService(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
new WorkOrderBoardMutationDataService(context),
|
||||||
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
return new WorkOrderUpliftService(
|
||||||
|
new UpliftDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
new WorkOrderDetailDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context),
|
||||||
|
new UserDataService(context),
|
||||||
|
TimeProvider.System,
|
||||||
|
Options.Create(new ApprovalsOptions
|
||||||
|
{
|
||||||
|
UpliftTier1MaxUsd = 2500m,
|
||||||
|
Tier1Roles = new[] { "Approver" },
|
||||||
|
Tier2Roles = new[] { "Manager" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<WorkOrderBoardRowDto> BoardCreateWithVendorAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
bool? isAddOn = null)
|
||||||
|
{
|
||||||
|
return NewBoardCreateService(context).CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
|
VendorId = 5,
|
||||||
|
IsAddOn = isAddOn,
|
||||||
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<WorkOrderUpliftDto?> CreateUpliftAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int workOrderId,
|
||||||
|
decimal amount,
|
||||||
|
int accountId = 1)
|
||||||
|
{
|
||||||
|
return NewUpliftService(context).CreateAsync(
|
||||||
|
workOrderId,
|
||||||
|
new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra coil work" },
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(accountId: accountId),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(int WorkOrderId, int PrimaryDispatchId)> SeedWorkOrderAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
Dispatch primary,
|
||||||
|
params Dispatch[] others)
|
||||||
|
{
|
||||||
|
context.Dispatches.Add(primary);
|
||||||
|
context.Dispatches.AddRange(others);
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "SH00001",
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
PrimaryDispatchId = primary.Id,
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
return (1, primary.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_WithVendor_PrimaryDispatchBelongsToWorkOrder()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
|
||||||
|
var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId);
|
||||||
|
Assert.Equal(row.Id, dispatch.WorkOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_PatchApptDate_KeepsPrimaryDispatch()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id);
|
||||||
|
var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId);
|
||||||
|
workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||||
|
dispatch.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 };
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var patched = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ApptDate,
|
||||||
|
Value = "2026-10-05",
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion),
|
||||||
|
DispatchVersion = Convert.ToBase64String(dispatch.RowVersion),
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(row.PrimaryDispatchId, patched.PrimaryDispatchId);
|
||||||
|
Assert.Equal(1, await context.Dispatches.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_AutoApprovedUplift_IsListedAndConsumesAllowance()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 400m);
|
||||||
|
|
||||||
|
Assert.Equal("auto_approved", created!.Status);
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
row.Id,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
var uplift = Assert.Single(listed!);
|
||||||
|
Assert.Equal(created.Id, uplift.Id);
|
||||||
|
Assert.Equal(400m, await new UpliftDataService(context)
|
||||||
|
.SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_RequestBeyondRemainingAllowance_GoesToPendingWithWorkOrderNumber()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
await CreateUpliftAsync(context, row.Id, 400m);
|
||||||
|
|
||||||
|
var second = await CreateUpliftAsync(context, row.Id, 200m);
|
||||||
|
|
||||||
|
Assert.Equal("pending", second!.Status);
|
||||||
|
var (_, queue) = await new UpliftDataService(context)
|
||||||
|
.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None);
|
||||||
|
var queued = Assert.Single(queue);
|
||||||
|
Assert.Equal(second.Id, queued.Id);
|
||||||
|
Assert.Equal(row.Id, queued.WorkOrderId);
|
||||||
|
Assert.Equal(row.WoNumber, queued.WorkOrderNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_OwnerRevokesAutoApprovedUplift()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 1m);
|
||||||
|
|
||||||
|
var revoked = await NewUpliftService(context).RevokeAsync(
|
||||||
|
row.Id,
|
||||||
|
created!.Id,
|
||||||
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
Assert.Equal(0m, await new UpliftDataService(context)
|
||||||
|
.SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExAddOnWorkOrder_AfterScheduleClearsAddOn_UpliftIsListedOnWorkOrder()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context, isAddOn: true);
|
||||||
|
Assert.True(row.IsAddOn);
|
||||||
|
var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id);
|
||||||
|
workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var rescheduled = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = DateTime.UtcNow.Date.AddDays(60).ToString("yyyy-MM-dd"),
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion),
|
||||||
|
}, "actor-1");
|
||||||
|
Assert.False(rescheduled.IsAddOn);
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 1m);
|
||||||
|
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
row.Id,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(created!.Id, Assert.Single(listed!).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_SoftDeletedPrimaryDispatch_UsesOwnedLiveDispatch()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true },
|
||||||
|
new Dispatch { Id = 11, VendorId = 5, WorkOrderId = 1, NTEAmount = 800m });
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, workOrderId, 100m);
|
||||||
|
|
||||||
|
var request = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == created!.Id);
|
||||||
|
Assert.Equal(11, request.DispatchId);
|
||||||
|
Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount);
|
||||||
|
Assert.Equal(900m, (await context.Dispatches.SingleAsync(d => d.Id == 11)).NTEAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_OnlySoftDeletedPrimaryDispatch_IsRejectedWithoutPersisting()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true });
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => CreateUpliftAsync(context, workOrderId, 100m));
|
||||||
|
|
||||||
|
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
Assert.Empty(context.WorkOrderAuditLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PrimaryDispatchOwnedByAnotherWorkOrder_IsNotUsed()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 });
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m });
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => CreateUpliftAsync(context, workOrderId, 100m));
|
||||||
|
|
||||||
|
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_UnlinkedPrimaryDispatch_IsRejectedInsteadOfHidingTheUplift()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = null, NTEAmount = 1000m });
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => CreateUpliftAsync(context, workOrderId, 100m));
|
||||||
|
|
||||||
|
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PrimaryDispatchLinkedThroughDispatchWorkOrders_IsUsed()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 });
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m });
|
||||||
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10, WorkOrderId = workOrderId });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, workOrderId, 100m);
|
||||||
|
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
workOrderId,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(created!.Id, Assert.Single(listed!).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CrossTenant_CreateAndList_AreRejected()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, id: 2, name: "Other Corp");
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
await CreateUpliftAsync(context, row.Id, 1m);
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 1m, accountId: 2);
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
row.Id,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(accountId: 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(created);
|
||||||
|
Assert.Null(listed);
|
||||||
|
Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -289,7 +289,21 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||||
dispatch.WorkOrderId = null;
|
// SH-393: an unlinked primary is no longer an uplift target, so the shared-dispatch
|
||||||
|
// case (owned by another work order, linked to this one) carries the duplicate check.
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
});
|
||||||
|
dispatch.WorkOrderId = 2;
|
||||||
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||||
|
{
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
WorkOrderId = workOrder.Id,
|
||||||
|
});
|
||||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
{
|
{
|
||||||
Id = 100,
|
Id = 100,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue