Compare commits

...

37 commits

Author SHA1 Message Date
Alexandre Brandizzi
16b1e1c67d
Merge 7297e30212 into 203fc92e4a 2026-09-25 02:57:47 -03:00
Alexandre Brandizzi
203fc92e4a
Merge pull request #172 from Sea-Haven-Industries/fix/ab/sh-391-adv-search-date-range
fix(board-search): make the Advanced Filters date range narrow results (SH-391)
2026-09-25 05:53:42 +00:00
Alexandre Brandizzi
3b69b9fbcc
Merge pull request #178 from Sea-Haven-Industries/fix/ab/sh-398-pending-exposure
fix(uplifts): one per-path uplift amount for queue, approval and exposure
2026-09-25 05:46:17 +00:00
Alexandre Brandizzi
85f2e709c9 test(uplifts): board summary and notification delta use the per-path amount
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:36:17 -03:00
Alexandre Brandizzi
d33ba34db9 fix(vendor-portal): vendors revise only uplift requests they raised
A work-order request stores the requested increase, not a total. Letting
the vendor revise one after changes were requested rewrote RequestedNTE
as a total while it still read as a work-order request, corrupting its
amount and the NTE it would be approved to. Revise now answers not-found
for any request the vendor did not raise and leaves the row untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:35:06 -03:00
Alexandre Brandizzi
eb2b442775 fix(uplifts): one per-path uplift amount for queue, approval and exposure
Work-order requests store the requested increase in RequestedNTE; vendor
portal requests store the requested NTE total. The queue Delta, the
pending and approved exposure totals, the work-order uplift list, the
board summary and the notification Delta now all read one definition
(UpliftAmount) that honours both meanings and translates to SQL.

Approving a work-order request now adds its increase to the dispatch NTE
instead of replacing the NTE with the increase; vendor requests still end
at their requested total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:32:58 -03:00
Alexandre Brandizzi
f40ad7c1ef fix(uplifts): pending exposure header sums the row deltas
The approvals header summed the whole RequestedNTE for work-order-path
requests, while each Pending row shows RequestedNTE - CurrentNTE. When a
work order already had an NTE the header overstated exposure by that NTE.

The header now sums the same Delta the rows display, over the same rows
the Pending tab lists (non-deleted request on a non-deleted dispatch).
The unused duplicate aggregate is removed so one definition remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:10:41 -03:00
Alexandre Brandizzi
67cd9c589b fix(board-search): keep undated rows for clients that omit includeDateless (SH-391)
The strict date range made undated open work orders disappear for every
client that predates the includeDateless flag. The frontend on main sends
the all-weeks window (2000-01-01..2099-12-31) for "no range", the
1970-01-01..2099-12-31 window for the pinned Unassigned queue, and no date
input at all for the WO# duplicate lookup. None of those send the flag, so
deploying this backend before the frontend would have dropped undated WOs
from all three flows.

includeDateless is now optional. An explicit value still wins. When it is
omitted, a request with no date input or with an all-weeks Custom window
keeps its open undated rows, as before SH-391. Any other range stays
strict. The backend and frontend can therefore deploy in either order.
2026-09-25 01:41:49 -03:00
Alexandre Brandizzi
50e5553e57
Merge pull request #177 from Sea-Haven-Industries/fix/ab/sh-397-uplift-decision-audit
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
fix(uplifts): let admins decide uplifts whose dispatch has no work order
2026-09-25 02:45:18 +00:00
Alexandre Brandizzi
b2b9fc3588 test(uplifts): cover escalation audit on a dispatch without an owning work order 2026-09-24 23:37:58 -03:00
Alexandre Brandizzi
59b8cdaf7d
Merge pull request #168 from Sea-Haven-Industries/fix/sh-327-request-uplifts-permission
fix(uplifts): enforce request permission at service boundary
2026-09-25 02:35:19 +00:00
Alexandre Brandizzi
c5d82a996a fix(uplifts): audit uplift decisions on the dispatch's resolved work order
Approve, reject, request-changes, expiry and escalation staged their work
order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs
requires a real work order, so any uplift on a dispatch without an owning
work order failed to save: the decision returned a 500 and the request stayed
Pending, and the expiry sweep failed on it every run.

The audit now goes to the work order the uplift resolves to through the
existing owner-or-linked read that revoke already uses. When none resolves,
the status change is saved on the request and no audit row is written.
2026-09-24 23:30:16 -03:00
Alexandre Brandizzi
8185875ad2
Merge pull request #176 from Sea-Haven-Industries/fix/ab/sh-327-admin-approves-uplifts
fix(uplifts): Admin passes uplift approval checks regardless of tier config (SH-327)
2026-09-25 02:13:26 +00:00
Alexandre Brandizzi
beb091fcc8 Merge remote-tracking branch 'origin/main' into lane/sh-327
# Conflicts:
#	Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
2026-09-24 23:00:18 -03:00
Alexandre Brandizzi
1e2f39a5fa chore(uplifts): drop ticket key from source comments 2026-09-24 22:57:09 -03:00
Alexandre Brandizzi
7591869462
Merge pull request #169 from Sea-Haven-Industries/hotfix/sh-387-concurrent-uplift
fix: return conflict for concurrent dispatch uplift requests
2026-09-25 01:54:50 +00:00
Alexandre Brandizzi
26b4aaf1b2 test(uplifts): cover Admin request-changes and evidence download with empty tier config (SH-327) 2026-09-24 22:51:57 -03:00
Alexandre Brandizzi
cfb05a906f fix(uplifts): Admin passes uplift approval checks regardless of tier config (SH-327)
UserCanApprove only accepted roles listed in Approvals:Tier1Roles/Tier2Roles,
so an environment whose config omits Admin denied every approval surface to
admins: can-approve, per-row CanDecide, approve/reject/request-changes and
evidence download. Admin now short-circuits the check; tier-role config still
governs every other role.
2026-09-24 22:34:32 -03:00
Alexandre Brandizzi
b116e71d16 test(uplifts): grant the SH-393 ownership fixture actor RequestUplifts
Uplift creation now checks the actor's RequestUplifts permission, so the
ownership tests construct the service with the permission services and seed
their actor as an Admin.
2026-09-24 22:31:07 -03:00
Alexandre Brandizzi
cc7cda4818 Merge remote-tracking branch 'origin/main' into lane/sh-327 2026-09-24 22:28:15 -03:00
Alexandre Brandizzi
2be74b261f test(uplifts): share conflict fixture dispatch through owned and linked work orders
After SH-393 the uplift dispatch resolves only through dispatches the work
order owns or links through DispatchWorkOrders. Model the shared dispatch
that way so the concurrent-insert conflict test exercises the conflict
mapping again; assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:28:04 -03:00
Alexandre Brandizzi
24ad21d7f2 Merge remote-tracking branch 'origin/main' into lane/sh-387 2026-09-24 22:25:54 -03:00
Alexandre Brandizzi
7b7df4463e
Merge pull request #174 from Sea-Haven-Industries/fix/ab/sh-393-uplift-scope
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
fix(uplifts): uplifts created from a work order show on that work order (SH-393)
2026-09-25 00:54:42 +00:00
Alexandre Brandizzi
76b13fb5da
Merge pull request #170 from Sea-Haven-Industries/fix/ab/sh-377-atomic-team-member-create
fix: make team member creation atomic
2026-09-25 00:52:34 +00:00
Alexandre Brandizzi
ce3b241991 test(uplifts): prove board-create dispatch ownership persists relationally (SH-393)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:45:00 -03:00
Alexandre Brandizzi
89376e99e4 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>
2026-09-24 21:22:13 -03:00
Alexandre Brandizzi
fddb7b7909 test: verify team member commit and rollback 2026-09-23 03:49:57 -03:00
Alexandre Brandizzi
5aa3a6f820 test: exercise team member rollback through DI 2026-09-23 03:44:44 -03:00
Alexandre Brandizzi
3b626cca58 fix: make team member creation atomic 2026-09-23 03:35:23 -03:00
Alexandre Brandizzi
0ca9b767ed fix: distinguish uplift request key conflicts 2026-09-23 01:39:16 -03:00
Alexandre Brandizzi
8af9ad076f fix: map concurrent uplift inserts to conflict 2026-09-23 01:26:15 -03:00
Alexandre Brandizzi
0b39d92723 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
2026-09-23 00:57:01 -03:00
Alexandre Brandizzi
50a2cbab5f test(uplifts): correct current-role denial fixtures 2026-09-22 23:39:51 -03:00
Alexandre Brandizzi
e1632bc3bf fix(uplifts): expose only allowlisted denial copy 2026-09-22 23:35:07 -03:00
Alexandre Brandizzi
f4284badf2 fix(uplifts): place forbidden handler on request endpoint 2026-09-22 23:30:14 -03:00
Alexandre Brandizzi
ff6a5945f1 fix(uplifts): honor current permissions and denial contract 2026-09-22 23:24:17 -03:00
Alexandre Brandizzi
9156107e72 fix(uplifts): enforce request permission at service boundary 2026-09-22 23:09:47 -03:00
44 changed files with 7007 additions and 134 deletions

View file

@ -319,7 +319,7 @@ public sealed class TeamMemberServiceTests
[Fact]
public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
{
var service = NewService(out var userManager, out var roleManager, out _, out _, out _, out _);
var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _);
userManager
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
@ -332,10 +332,57 @@ public sealed class TeamMemberServiceTests
result.Success.Should().BeFalse();
result.Error.Should().Be("Email is already in use.");
userData.Verify(
data => data.ExecuteTransactionalAsync(It.IsAny<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
[Fact]
public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation()
{
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _);
ApplicationUser? created = null;
userManager
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
userManager
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(user =>
{
user.Id = "mid-fail-user";
created = user;
})
.ReturnsAsync(IdentityResult.Success);
userManager
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
.ReturnsAsync(IdentityResult.Success);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
areas
.Setup(data => data.ReplaceAsync(
"mid-fail-user",
It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
overrides
.Setup(data => data.SetOverridesAsync(
"mid-fail-user",
It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("injected mid-sequence failure"));
var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None));
failure.Should().BeOfType<InvalidOperationException>()
.Which.Message.Should().Be("injected mid-sequence failure");
created.Should().NotBeNull();
areas.Verify(
data => data.ReplaceAsync("mid-fail-user", It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
private static TeamMemberService NewService(
out Mock<UserManager<ApplicationUser>> userManager,
out Mock<RoleManager<IdentityRole>> roleManager,
@ -350,6 +397,11 @@ public sealed class TeamMemberServiceTests
overrides = new Mock<ITeamPermissionOverrideDataService>();
userData = new Mock<IUserDataService>();
permissions = new Mock<ITeamPermissionService>();
userData
.Setup(data => data.ExecuteTransactionalAsync(
It.IsAny<Func<CancellationToken, Task>>(),
It.IsAny<CancellationToken>()))
.Returns<Func<CancellationToken, Task>, CancellationToken>((callback, token) => callback(token));
return new TeamMemberService(
userManager.Object,
roleManager.Object,

View file

@ -0,0 +1,206 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
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 Api.SeaHavenIndustries.Tests;
// Admin passes every permission check regardless of stored configuration.
// Uplift approval authority is otherwise driven by Approvals:Tier1Roles/Tier2Roles,
// which may be empty in a deployed environment; Admin must still be able to decide.
public sealed class UpliftAdminApprovalTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "user-42") };
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
}
private static UpliftService NewService(ApplicationDbContext context, ApprovalsOptions? options = null) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
new NoopDocumentStorage(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()));
private static async Task<Dispatch> SeedPendingAsync(ApplicationDbContext context, int requiredTier)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed",
NTEAmount = 1000m,
DispatchNumber = "DIS-1"
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
CurrentNTE = 1000m,
RequestedNTE = 1800m,
VendorReason = "reason",
Status = UpliftStatus.Pending,
RequiredTier = requiredTier,
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
return dispatch;
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void CanApprove_Admin_WithEmptyTierConfig_IsTrue(int tier)
{
using var context = NewContext();
var service = NewService(context);
service.CanApprove(UserWithRoles("Admin"), tier).Should().BeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task List_Admin_WithEmptyTierConfig_CanDecidePendingRows(int tier)
{
using var context = NewContext();
var dispatch = await SeedPendingAsync(context, tier);
var service = NewService(context);
var queue = await service.ListAsync(UserWithRoles("Admin"), UpliftStatus.Pending, null, 1, 25, CancellationToken.None);
var forDispatch = await service.ListForDispatchAsync(UserWithRoles("Admin"), dispatch.Id, CancellationToken.None);
queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeTrue();
forDispatch.Should().ContainSingle().Which.CanDecide.Should().BeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task Approve_Admin_WithEmptyTierConfig_Succeeds(int tier)
{
using var context = NewContext();
await SeedPendingAsync(context, tier);
var service = NewService(context);
var result = await service.ApproveAsync(UserWithRoles("Admin"), 1, "ok", CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Approved);
context.Dispatches.Single().NTEAmount.Should().Be(1800m);
}
[Fact]
public async Task Reject_Admin_WithEmptyTierConfig_Succeeds()
{
using var context = NewContext();
await SeedPendingAsync(context, 2);
var service = NewService(context);
var result = await service.RejectAsync(UserWithRoles("Admin"), 1, "no", CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Rejected);
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
}
[Fact]
public async Task RequestChanges_Admin_WithEmptyTierConfig_Succeeds()
{
using var context = NewContext();
await SeedPendingAsync(context, 2);
var service = NewService(context);
var result = await service.RequestChangesAsync(UserWithRoles("Admin"), 1, "more detail", CancellationToken.None);
result.Status.Should().Be(UpliftStatus.ChangesRequested);
}
[Fact]
public async Task EvidenceDownload_Admin_WithEmptyTierConfig_ReturnsFile()
{
using var context = NewContext();
var dispatch = await SeedPendingAsync(context, 2);
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = dispatch.VendorId,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
OriginalFileName = "invoice.pdf",
StoredFileName = "evidence.bin",
ContentType = "application/pdf",
SizeBytes = 4,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = "UpliftEvidence",
Version = 1
});
context.DispatchUpliftRequests.Single().EvidenceDocumentId = 1;
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None);
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
result.FileName.Should().Be("invoice.pdf");
}
[Theory]
[InlineData("Dispatcher")]
[InlineData("Scheduler")]
public async Task NonAdminRole_WithEmptyTierConfig_IsStillDenied(string role)
{
using var context = NewContext();
await SeedPendingAsync(context, 1);
var service = NewService(context);
var user = UserWithRoles(role);
service.CanApprove(user, 1).Should().BeFalse();
service.CanApprove(user, 2).Should().BeFalse();
var queue = await service.ListAsync(user, UpliftStatus.Pending, null, 1, 25, CancellationToken.None);
queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeFalse();
var act = () => service.ApproveAsync(user, 1, "ok", CancellationToken.None);
await act.Should().ThrowAsync<UpliftForbiddenException>();
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Pending);
}
[Fact]
public void Tier1OnlyRole_ApprovesTier1_ButNotTier2()
{
using var context = NewContext();
var service = NewService(context, new ApprovalsOptions { Tier1Roles = new[] { "Approver" } });
var user = UserWithRoles("Approver");
service.CanApprove(user, 1).Should().BeTrue();
service.CanApprove(user, 2).Should().BeFalse();
}
private sealed class NoopDocumentStorage : IVendorDocumentStoragePort
{
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => Task.CompletedTask;
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => new MemoryStream();
public void Delete(int vendorId, int dispatchId, string storedFileName) { }
}
}

View file

@ -278,7 +278,8 @@ public sealed class UpliftQueueReadTests
item.RequestedByName.Should().Be("Gateway");
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
item.VendorReason.Should().Be("Scope grew");
item.Delta.Should().Be(300m);
// A work-order request (createdby set) stores the increase itself in RequestedNTE.
item.Delta.Should().Be(400m);
item.Status.Should().Be("Pending");
}
@ -558,7 +559,7 @@ public sealed class UpliftQueueReadTests
}
[Fact]
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
@ -567,10 +568,10 @@ public sealed class UpliftQueueReadTests
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
// Vendor-portal rows (createdby null) store the requested new NTE total,
// so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350.
// so their increase is 400 - 100 = 300 and 600 - 250 = 350.
Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m),
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
// Work-order-path rows (createdby set) store the requested increment: 90.
// Work-order rows (createdby set) store the increase itself: 90.
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
// Non-pending rows never add pending exposure.
Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m));
@ -584,6 +585,38 @@ public sealed class UpliftQueueReadTests
result.PendingExposureTotal.Should().Be(740m);
}
[Fact]
public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC");
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "HVAC");
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
var deletedDispatch = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
deletedDispatch.IsDeleted = true;
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
// Raised from the work order, which already carries a 3,000 NTE: RequestedNTE
// is the 5,000,000,000 increase itself.
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"),
// Vendor-portal request for a 3,333 total on a 3,000 NTE: a 333 increase.
Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 3_333m, currentNte: 3_000m),
// A pending request on a deleted dispatch is not a queue row, so it adds nothing.
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Should().HaveCount(2);
result.Items.Select(i => i.Delta).Should().BeEquivalentTo(new[] { 5_000_000_000m, 333m });
result.PendingExposureTotal.Should().Be(5_000_000_333m);
result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta));
}
// --- Approved-on-WO exposure totals ---
[Fact]

View file

@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests
private static VendorPortalService NewPortalService(
ApplicationDbContext context,
IEmailSender emailSender,
IVendorDocumentStoragePort? storage = null)
IVendorDocumentStoragePort? storage = null,
IUpliftDataService? upliftData = null)
{
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
new UpliftDataService(context),
upliftData ?? new UpliftDataService(context),
commentData.Object,
Mock.Of<IUserDataService>(),
emailSender,
@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests
result.RequiredTier.Should().Be(2);
}
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException());
var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var act = () => service.RequestUpliftAsync(
session!,
dispatch.Id,
1500m,
"reason",
null,
1,
CancellationToken.None);
await act.Should().ThrowAsync<SeaHaven.Services.Exceptions.UpliftConflictException>();
}
// --- Evidence scan + cross-vendor/dispatch scoping ---
[Fact]
@ -388,6 +419,44 @@ public sealed class UpliftWorkflowTests
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised");
}
[Fact]
public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 600m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
// Raised from the work order by an internal user and sent back for changes:
// RequestedNTE holds the 90 increase, not a total.
var workOrderRequest = new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
CurrentNTE = 600m,
RequestedNTE = 90m,
Status = UpliftStatus.ChangesRequested,
RequiredTier = 1,
VendorReason = "Extra parts",
RequestedByVendorName = "Alex Dispatcher",
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = new DateTime(2026, 3, 1),
};
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var act = () => service.ReviseUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, 900m, "vendor total", 1, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
after.Status.Should().Be(UpliftStatus.ChangesRequested);
after.RequestedNTE.Should().Be(90m);
after.CurrentNTE.Should().Be(600m);
after.VendorReason.Should().Be("Extra parts");
after.createdby.Should().Be("dispatcher-1");
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_revised");
}
[Fact]
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt()
{
@ -650,6 +719,8 @@ public sealed class UpliftWorkflowTests
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny<CancellationToken>()))
.ReturnsAsync(99);
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);

View file

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -309,6 +310,34 @@ public class VendorPortalControllerTests
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RequestUpliftAsync(
Session,
10,
1500m,
"Need more parts",
It.IsAny<string?>(),
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftConflictException());
var controller = NewController(service);
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
{
RequestedNTE = 1500m,
Reason = "Need more parts",
});
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task RequestUplift_Success_ReturnsResult()
{

View file

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
@ -78,6 +79,75 @@ public sealed class WorkOrderUpliftControllerTests
Assert.Equal(12, created.Id);
}
[Fact]
public async Task CreateUplift_RequestPermissionDenied_ReturnsExact403Message()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage));
var controller = NewController(service, "Dispatcher");
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
[Fact]
public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("SECRET-internal-tier-detail"));
var controller = NewController(service, "Dispatcher");
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().NotContain("SECRET-internal-tier-detail");
response.Message.Should().Contain("reference");
}
[Fact]
public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftConflictException());
var controller = NewController(service);
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" },
CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task CancelUplift_NotFound_Returns404()
{

View file

@ -0,0 +1,138 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Exceptions;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class WorkOrderUpliftServiceConflictTests
{
[Fact]
public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict()
{
// Dispatch 10 is legitimately shared: work order 1 owns it and work order 2 links to
// it through DispatchWorkOrders, so the uplift dispatch resolves to it for both.
var sharedDispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
Status = "Scheduled",
NTEAmount = 1000m,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { DispatchId = 10, WorkOrderId = 2 },
},
};
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync(
It.IsIn(1, 2),
10,
It.IsAny<CancellationToken>()))
.ReturnsAsync(sharedDispatch);
var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var savesArrived = 0;
var winner = 0;
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<WorkOrderUpliftDto?>>>(),
It.IsAny<CancellationToken>()))
.Returns<int, Func<CancellationToken, Task<WorkOrderUpliftDto?>>, CancellationToken>(
(_, work, cancellationToken) => work(cancellationToken));
upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(0m);
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Returns(async () =>
{
if (Interlocked.Increment(ref savesArrived) == 2)
bothAtInsert.TrySetResult();
await bothAtInsert.Task.WaitAsync(TimeSpan.FromSeconds(5));
if (Interlocked.Exchange(ref winner, 1) != 0)
throw new UpliftDispatchConflictException();
});
var detailData = new Mock<IWorkOrderDetailDataService>();
detailData.Setup(x => x.ExistsAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.ReturnsAsync(true);
detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder
{
Id = workOrderId,
PrimaryDispatchId = 10,
WorkOrderType = WorkOrderType.PM,
LifecycleStatus = LifecycleStatus.Scheduled,
});
var dispatchData = new Mock<IDispatchDataService>();
dispatchData.Setup(x => x.GetByIdAsync(10))
.ReturnsAsync(sharedDispatch);
dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var accountResolver = new Mock<IWorkOrderAccountResolver>();
accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny<ClaimsPrincipal>())).Returns((int?)null);
var userData = new Mock<IUserDataService>();
userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, string>());
// SH-327: the requester must hold RequestUplifts before the create reaches the insert.
var permissionData = new Mock<ITeamPermissionOverrideDataService>();
permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamPermissionUserData { UserId = "dispatcher-1", RoleName = "Dispatcher" });
var service = new WorkOrderUpliftService(
upliftData.Object,
dispatchData.Object,
detailData.Object,
accountResolver.Object,
userData.Object,
permissionData.Object,
new TeamPermissionPolicy(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
}));
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
"test"));
static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture(
Func<Task<WorkOrderUpliftDto?>> create)
{
try
{
return (await create(), null);
}
catch (Exception exception)
{
return (null, exception);
}
}
var outcomes = await Task.WhenAll(
Capture(() => service.CreateAsync(1, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)),
Capture(() => service.CreateAsync(2, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)));
Assert.Single(outcomes, outcome => outcome.Result != null);
Assert.IsType<UpliftConflictException>(Assert.Single(outcomes, outcome => outcome.Error != null).Error);
upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
}

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -217,6 +218,10 @@ namespace Api.SeaHavenIndustries.Controllers
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (UpliftConflictException ex)
{
return Conflict(new Response { Status = "Error", Message = ex.Message });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });

View file

@ -164,13 +164,20 @@ namespace Api.SeaHavenIndustries.Controllers
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(new DataResponse { Status = "Success", Data = created });
}
catch (UpliftConflictException ex)
{
return Conflict(new Response { Status = "Error", Message = ex.Message });
}
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") });
var message = ex.Message == UpliftForbiddenException.RequestUpliftsDeniedMessage
? UpliftForbiddenException.RequestUpliftsDeniedMessage
: _logger.Sanitize(ex, "You are not authorized to perform this action");
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = message });
}
catch (InvalidOperationException ex)
{

View file

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

View file

@ -100,6 +100,8 @@ namespace Data.SeaHavenIndustries
public string? DecidedByLastName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
public decimal Amount { get; set; }
public string? VendorReason { get; set; }
public int RequiredTier { get; set; }
public string? Status { get; set; }
@ -127,6 +129,8 @@ namespace Data.SeaHavenIndustries
public int DispatchId { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
public decimal Amount { get; set; }
public string? VendorReason { get; set; }
public string? Status { get; set; }
public int RequiredTier { get; set; }

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Exceptions;
public sealed class UpliftDispatchConflictException : Exception
{
public UpliftDispatchConflictException()
: base("An active uplift request already exists for this dispatch.")
{
}
}

View file

@ -0,0 +1,33 @@
using System.Linq.Expressions;
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// The single definition of an uplift's amount: the NTE increase being requested.
/// The two creation paths store different meanings in RequestedNTE. Vendor-portal
/// requests store the requested new NTE total, so the amount is RequestedNTE minus
/// the NTE at request time. Work-order requests store the increase itself. Vendor
/// sessions have no identity user, so createdby is null only on vendor-portal rows.
/// </summary>
public static class UpliftAmount
{
/// <summary>Translatable form for EF queries and aggregates.</summary>
public static readonly Expression<Func<DispatchUpliftRequest, decimal>> ForQuery =
u => u.createdby == null
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
: u.RequestedNTE;
private static readonly Func<DispatchUpliftRequest, decimal> Compiled = ForQuery.Compile();
public static decimal Of(DispatchUpliftRequest request) => Compiled(request);
public static decimal Of(string? createdBy, decimal requestedNte, decimal? currentNte) =>
Compiled(new DispatchUpliftRequest
{
createdby = createdBy,
RequestedNTE = requestedNte,
CurrentNTE = currentNte,
});
}
}

View file

@ -255,6 +255,8 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderId = u.Dispatch!.WorkOrderId!.Value,
u.Status,
u.RequestedNTE,
u.CurrentNTE,
u.createdby,
u.CreatedDate
});
@ -268,6 +270,8 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderId = link.WorkOrderId,
u.Status,
u.RequestedNTE,
u.CurrentNTE,
u.createdby,
u.CreatedDate
});
@ -276,7 +280,7 @@ namespace SeaHaven.DataServices.Helpers
l.UpliftId,
l.WorkOrderId,
l.Status,
l.RequestedNTE,
UpliftAmount.Of(l.createdby, l.RequestedNTE, l.CurrentNTE),
l.CreatedDate)));
}
@ -316,7 +320,7 @@ namespace SeaHaven.DataServices.Helpers
pendingCount,
HasUplift: true,
primary?.Status,
primary?.RequestedNTE);
primary?.Amount);
}
return result;
@ -337,7 +341,7 @@ namespace SeaHaven.DataServices.Helpers
int UpliftId,
int WorkOrderId,
string Status,
decimal RequestedNTE,
decimal Amount,
DateTime? CreatedDate);
private sealed record BoardUpliftAgg(

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
@ -457,6 +458,7 @@ namespace SeaHaven.DataServices.Implementation
u.Id,
u.CurrentNTE,
u.RequestedNTE,
u.createdby,
u.VendorReason,
u.Status,
u.RequiredTier,
@ -474,7 +476,7 @@ namespace SeaHaven.DataServices.Implementation
Id = u.Id,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
Delta = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,

View file

@ -2,6 +2,8 @@ using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Exceptions;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -108,6 +110,7 @@ namespace SeaHaven.DataServices.Implementation
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
CurrentNTE = x.u.CurrentNTE,
RequestedNTE = x.u.RequestedNTE,
Amount = UpliftAmount.Of(x.u.createdby, x.u.RequestedNTE, x.u.CurrentNTE),
VendorReason = x.u.VendorReason,
RequiredTier = x.u.RequiredTier,
Status = x.u.Status,
@ -127,16 +130,6 @@ namespace SeaHaven.DataServices.Implementation
return (total, items);
}
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken)
{
return _context.DispatchUpliftRequests
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& u.Status == "Pending")
.SumAsync(u => u.createdby == null
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
: u.RequestedNTE, cancellationToken);
}
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return await (from u in _context.DispatchUpliftRequests
@ -152,6 +145,7 @@ namespace SeaHaven.DataServices.Implementation
DispatchId = u.DispatchId,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
@ -192,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
DispatchId = u.DispatchId,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
@ -336,6 +331,25 @@ namespace SeaHaven.DataServices.Implementation
.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)
{
return ForWorkOrder(workOrderId)
@ -382,25 +396,14 @@ namespace SeaHaven.DataServices.Implementation
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& dispatchIds.Contains(u.DispatchId)
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
.GroupBy(u => u.DispatchId)
// Summing each request's amount (the granted increase) keeps sequential
// approvals from double-counting whole NTE totals in both buckets.
.GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery)
.Select(g => new
{
DispatchId = g.Key,
// The two creation paths store different meanings in RequestedNTE.
// Vendor-portal rows store the requested new NTE total, so the
// granted amount is RequestedNTE - CurrentNTE; work-order-path rows
// store the granted increment directly. Vendor sessions have no
// identity user, so createdby is null only on vendor-portal rows. Summing
// granted amounts keeps sequential approvals from double-counting whole
// NTE totals. This applies to both buckets.
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
.Sum(x => (decimal?)(x.createdby == null
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
: x.RequestedNTE)),
AdminApproved = g.Where(x => x.Status == "Approved")
.Sum(x => (decimal?)(x.createdby == null
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
: x.RequestedNTE))
g.Key.DispatchId,
g.Key.Status,
Total = g.Sum()
})
.ToListAsync(cancellationToken);
@ -418,17 +421,20 @@ namespace SeaHaven.DataServices.Implementation
totals[workOrderId] = total;
}
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
if (sum.Status == "NoApprovalRequired")
total.AutoApprovedTotal += sum.Total;
else
total.AdminApprovedTotal += sum.Total;
}
}
return totals.Values.ToList();
}
// SH-207: queue-wide pending exposure for the approvals header. Mirrors the
// approved-exposure convention (sum of RequestedNTE) over every non-deleted
// Pending request on a non-deleted dispatch, independent of page or filters.
// Queue-wide pending exposure for the approvals header: the sum of the amount each
// Pending queue row displays (UpliftAmount), over the same rows the Pending tab
// lists (non-deleted request on a non-deleted dispatch), independent of page or
// filters.
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
@ -436,7 +442,8 @@ namespace SeaHaven.DataServices.Implementation
&& u.Status == "Pending"
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
.SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m;
.Select(UpliftAmount.ForQuery)
.SumAsync(cancellationToken);
}
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
@ -525,7 +532,58 @@ namespace SeaHaven.DataServices.Implementation
public async Task SaveChangesAsync(CancellationToken cancellationToken)
{
await _context.SaveChangesAsync(cancellationToken);
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException exception) when (IsActiveDispatchUniqueIndexViolation(exception))
{
throw new UpliftDispatchConflictException();
}
}
private bool IsActiveDispatchUniqueIndexViolation(DbUpdateException exception)
{
var provider = _context.Database.ProviderName;
var databaseException = exception.GetBaseException();
var exceptionType = databaseException.GetType();
if (provider?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) == true
&& exceptionType.FullName == "Microsoft.Data.SqlClient.SqlException")
{
var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?;
return number is 2601 or 2627
&& IsActiveDispatchIndexViolationMessage(databaseException.Message);
}
if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true
&& exceptionType.FullName == "Microsoft.Data.Sqlite.SqliteException")
{
var extendedCode = exceptionType.GetProperty("SqliteExtendedErrorCode")?.GetValue(databaseException) as int?;
const string uniqueConstraintPrefix = "UNIQUE constraint failed: ";
var message = databaseException.Message;
var prefixIndex = message.IndexOf(uniqueConstraintPrefix, StringComparison.OrdinalIgnoreCase);
if (extendedCode != 2067 || prefixIndex < 0)
return false;
var columnsStart = prefixIndex + uniqueConstraintPrefix.Length;
var columnsEnd = message.IndexOf('\'', columnsStart);
if (columnsEnd < 0)
columnsEnd = message.Length;
var columns = message[columnsStart..columnsEnd].Trim();
return string.Equals(
columns,
"DispatchUpliftRequests.DispatchId",
StringComparison.OrdinalIgnoreCase);
}
return false;
}
internal static bool IsActiveDispatchIndexViolationMessage(string message)
{
const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'";
return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase);
}
public async Task<T> ExecuteWorkOrderMutationAsync<T>(

View file

@ -166,6 +166,25 @@ namespace SeaHaven.DataServices.Implementation
await transaction.CommitAsync(cancellationToken);
}
public async Task ExecuteTransactionalAsync(
Func<CancellationToken, Task> callback,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(callback);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
await callback(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(CancellationToken.None);
throw;
}
}
public async Task<string?> GetEmailByIdAsync(
string userId, CancellationToken cancellationToken)
{

View file

@ -6,7 +6,6 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
Task<decimal> GetPendingExposureAsync(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);
@ -19,6 +18,10 @@ namespace SeaHaven.DataServices.Interfaces
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
Task<bool> HasPendingAsync(int dispatchId, 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);
// Approval queue read contract: set-based per-work-order exposure totals covering
// auto-approved and admin-approved uplift amounts (pending, rejected,

View file

@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> callback, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}

View file

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")]

View file

@ -104,6 +104,9 @@ namespace SeaHaven.Services.DTOs
public class UpliftForbiddenException : Exception
{
public const string RequestUpliftsDeniedMessage =
"Your role can't request uplifts on this work order.";
public UpliftForbiddenException(string message) : base(message) { }
}

View file

@ -90,9 +90,11 @@ namespace SeaHaven.Services.DTOs
/// <summary>
/// When true, rows with no date (no Schedule On) are returned alongside
/// the in-range rows. Sent for searches with no date range selected; an
/// explicit range leaves it false so the range strictly narrows (SH-391).
/// explicit range leaves it unset or false so the range strictly narrows
/// (SH-391). When it is omitted, clients that predate the flag keep their
/// undated rows: see <see cref="WorkOrderSearchDateRangeResolver.ResolveIncludeDateless"/>.
/// </summary>
public bool IncludeDateless { get; set; }
public bool? IncludeDateless { get; set; }
public List<string>? Sites { get; set; }
public List<string>? Regions { get; set; }
public List<WorkOrderType>? Types { get; set; }

View file

@ -0,0 +1,9 @@
namespace SeaHaven.Services.Exceptions;
public sealed class UpliftConflictException : Exception
{
public UpliftConflictException()
: base("An active uplift request already exists for the associated dispatch. Refresh and retry.")
{
}
}

View file

@ -15,6 +15,36 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderSearchDateRangeResolver
{
// Widest windows the frontend sends for "every week": the no-range search
// (ADVANCED_SEARCH_ALL_WEEKS_FROM/TO, 2000-01-01..2099-12-31) and the pinned
// Unassigned queue (UNASSIGNED_QUEUE_DATE_FROM/TO, 1970-01-01..2099-12-31).
private static readonly DateOnly AllWeeksFrom = new(2000, 1, 1);
private static readonly DateOnly AllWeeksTo = new(2099, 12, 31);
/// <summary>
/// Whether a board search adds open undated rows to its date range (SH-391).
/// An explicit <paramref name="includeDateless"/> wins. When it is omitted,
/// as by clients that predate the flag, a search with no date input or with
/// an all-weeks Custom window still returns undated rows, as before SH-391:
/// those requests mean "no range selected". Any other range is strict.
/// </summary>
public static bool ResolveIncludeDateless(
bool? includeDateless,
WorkOrderAdvancedSearchDatePreset? preset,
DateOnly? dateFrom,
DateOnly? dateTo)
{
if (includeDateless.HasValue)
return includeDateless.Value;
if (!preset.HasValue && !dateFrom.HasValue && !dateTo.HasValue)
return true;
return preset == WorkOrderAdvancedSearchDatePreset.Custom
&& dateFrom <= AllWeeksFrom
&& dateTo >= AllWeeksTo;
}
public static WorkOrderSearchDateRange Resolve(
WorkOrderAdvancedSearchDatePreset? preset,
DateOnly? dateFrom,

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
@ -42,7 +43,7 @@ namespace SeaHaven.Services.Helpers
{
Id = row.Id,
Status = ToFrontendStatus(row.Status),
Amount = row.RequestedNTE,
Amount = row.Amount,
Notes = row.VendorReason ?? "",
RequestedAt = row.CreatedDate,
RequestedById = row.CreatedByUserId,
@ -58,7 +59,7 @@ namespace SeaHaven.Services.Helpers
{
Id = request.Id,
Status = ToFrontendStatus(request.Status),
Amount = request.RequestedNTE,
Amount = UpliftAmount.Of(request),
Notes = request.VendorReason ?? "",
RequestedAt = request.CreatedDate,
RequestedById = request.createdby,

View file

@ -52,10 +52,6 @@ public sealed class TeamMemberService : ITeamMemberService
if (await _userManager.FindByEmailAsync(email!) is not null)
return Failure("Email is already in use.");
var roleError = await EnsureRoleAsync(role!);
if (roleError is not null)
return Failure(roleError);
var now = DateTime.UtcNow;
var user = new ApplicationUser
{
@ -72,39 +68,44 @@ public sealed class TeamMemberService : ITeamMemberService
PendingRegistrationCreatedDate = now
};
IdentityResult createResult;
try
{
createResult = await _userManager.CreateAsync(user);
}
catch (DbUpdateException)
{
// A concurrent create won the race on the unique user-name index
// between the FindByEmailAsync check above and this insert. Surface
// the same conflict message instead of letting the database
// exception bubble up as a 500.
return Failure("Email is already in use.");
}
await _userDataService.ExecuteTransactionalAsync(
async transactionCancellationToken =>
{
var roleError = await EnsureRoleAsync(role!);
if (roleError is not null)
throw new TeamMemberCreateException(roleError);
if (!createResult.Succeeded)
return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
IdentityResult createResult;
try
{
createResult = await _userManager.CreateAsync(user);
}
catch (DbUpdateException)
{
// A concurrent create won the race on the unique user-name index
// between the FindByEmailAsync check above and this insert. Surface
// the same conflict message instead of letting the database
// exception bubble up as a 500.
throw new TeamMemberCreateException("Email is already in use.");
}
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
if (!addRoleResult.Succeeded)
{
await _userManager.DeleteAsync(user);
return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
}
if (!createResult.Succeeded)
throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
try
{
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
if (!addRoleResult.Succeeded)
throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
},
cancellationToken);
}
catch
catch (TeamMemberCreateException exception)
{
await _userManager.DeleteAsync(user);
throw;
return Failure(exception.Message);
}
return new CreateTeamMemberOutcomeDTO
@ -391,6 +392,13 @@ public sealed class TeamMemberService : ITeamMemberService
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
new() { Success = false, Error = error };
private sealed class TeamMemberCreateException : Exception
{
public TeamMemberCreateException(string error) : base(error)
{
}
}
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
new() { Success = false, Error = error };
}

View file

@ -61,9 +61,9 @@ namespace SeaHaven.Services.Implementation
req.DecidedAt = now;
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch?.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
NewValue = UpliftStatus.Expired,
@ -136,9 +136,9 @@ namespace SeaHaven.Services.Implementation
req.EscalatedAt = now;
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = "Pending",
NewValue = "Escalated",
@ -247,5 +247,19 @@ namespace SeaHaven.Services.Implementation
req.LastModificationTime = now;
await _upliftData.SaveChangesAsync(cancellationToken);
}
// The audit goes to the work order the uplift's dispatch belongs to (owner, else the
// one it is linked to). With neither, the status change is recorded on the request
// alone; a work-order id of 0 would fail the required audit foreign key and abort
// the sweep on every run.
private async Task StageAuditAsync(
int upliftId,
Func<int, WorkOrderAuditLog> buildEntry,
CancellationToken cancellationToken)
{
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
if (workOrderId is int resolved)
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
}
}
}

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
namespace SeaHaven.Services.Implementation
{
@ -23,7 +24,7 @@ namespace SeaHaven.Services.Implementation
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${UpliftAmount.Of(req):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
@ -36,7 +37,7 @@ namespace SeaHaven.Services.Implementation
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
{
var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken);
var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken);
var pendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken);
var mapped = items.Select(r => new UpliftListItemDTO
{
@ -56,7 +57,7 @@ namespace SeaHaven.Services.Implementation
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
Delta = r.Amount,
VendorReason = r.VendorReason,
RequiredTier = r.RequiredTier,
Status = UpliftStatus.ToCanonical(r.Status),
@ -115,7 +116,7 @@ namespace SeaHaven.Services.Implementation
DispatchId = r.DispatchId,
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
Delta = r.Amount,
VendorReason = r.VendorReason,
Status = UpliftStatus.ToCanonical(r.Status),
RequiredTier = r.RequiredTier,
@ -157,7 +158,12 @@ namespace SeaHaven.Services.Implementation
var now = _timeProvider.GetUtcNow().UtcDateTime;
var oldNTE = dispatch.NTEAmount ?? 0m;
dispatch.NTEAmount = req.RequestedNTE;
// Approval raises the NTE by the request's amount. A vendor-portal request
// stores its requested NTE total and ends exactly there; a work-order request
// stores the increase, so it is added to the dispatch's current NTE.
dispatch.NTEAmount = req.createdby == null
? req.RequestedNTE
: oldNTE + UpliftAmount.Of(req);
dispatch.LastModificationTime = now;
req.Status = UpliftStatus.Approved;
@ -166,13 +172,13 @@ namespace SeaHaven.Services.Implementation
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
OldValue = $"${oldNTE:F2}",
NewValue = $"${req.RequestedNTE:F2}",
NewValue = $"${dispatch.NTEAmount:F2}",
Action = "uplift_approved",
CreatedAt = now
}, cancellationToken);
@ -252,9 +258,9 @@ namespace SeaHaven.Services.Implementation
req.DecisionNote = note!.Trim();
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch?.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
@ -290,9 +296,9 @@ namespace SeaHaven.Services.Implementation
req.DecisionNote = note.Trim();
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = dispatch?.WorkOrderId ?? 0,
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
@ -307,6 +313,19 @@ namespace SeaHaven.Services.Implementation
public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier);
// The decision audit goes to the work order the uplift's dispatch belongs to (owner,
// else the one it is linked to). A dispatch with neither has no work order to audit
// against, so the decision is recorded on the request alone.
private async Task StageDecisionAuditAsync(
int upliftId,
Func<int, WorkOrderAuditLog> buildEntry,
CancellationToken cancellationToken)
{
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
if (workOrderId is int resolved)
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
}
// SH-101: authorized internal download of a Passed UpliftEvidence file. The data
// service resolves the document by the request's own linkage (no client-supplied
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
@ -335,8 +354,12 @@ namespace SeaHaven.Services.Implementation
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
}
// Admin passes every permission check regardless of stored configuration,
// so the tier-role lists only govern non-Admin approvers.
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
{
if (user.IsInRole("Admin")) return true;
var roles = RolesForTier(requiredTier);
foreach (var r in roles)
{

View file

@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -651,7 +652,14 @@ namespace SeaHaven.Services.Implementation
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
try
{
await _upliftData.SaveChangesAsync(cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
// Notification is best-effort and persisted separately from workflow state: a failure
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
@ -728,7 +736,9 @@ namespace SeaHaven.Services.Implementation
}
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
if (req == null) throw new KeyNotFoundException("Uplift request not found");
// A vendor revises only requests it raised. Work-order requests (createdby set)
// store the increase, not a total, so a vendor revise would corrupt their amount.
if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found");
if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
{

View file

@ -73,7 +73,11 @@ namespace SeaHaven.Services.Implementation
sortBy,
query.SortDir ?? "asc",
accountId,
query.IncludeDateless);
WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
query.IncludeDateless,
query.DatePreset,
query.DateFrom,
query.DateTo));
var result = await _searchDataService.SearchAsync(dataQuery);
var utcNow = DateTime.UtcNow;

View file

@ -249,10 +249,14 @@ namespace SeaHaven.Services.Implementation
_mutationData.TrackNewWorkOrder(workOrder);
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;
dispatch.WorkOrderId = workOrder.Id;
workOrder.PrimaryDispatchId ??= dispatch.Id;
dispatch.WorkOrderId ??= workOrder.Id;
}

View file

@ -4,6 +4,7 @@ using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
@ -19,6 +20,8 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IUserDataService _userData;
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
@ -28,6 +31,8 @@ namespace SeaHaven.Services.Implementation
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
{
@ -36,6 +41,8 @@ namespace SeaHaven.Services.Implementation
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
}
@ -61,6 +68,8 @@ namespace SeaHaven.Services.Implementation
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
await EnsureCanRequestUpliftAsync(user, cancellationToken);
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
@ -69,17 +78,49 @@ namespace SeaHaven.Services.Implementation
var notes = request.Notes?.Trim() ?? "";
var accountFilter = _accountResolver.ResolveAccountFilter(user);
return await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
ct => CreateLockedAsync(
try
{
return await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
request.Amount,
notes,
userId,
requesterName,
accountFilter,
ct),
cancellationToken);
ct => CreateLockedAsync(
workOrderId,
request.Amount,
notes,
userId,
requesterName,
accountFilter,
ct),
cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
}
private async Task EnsureCanRequestUpliftAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
if (permissionUser is null)
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
if (!_permissionPolicy.IsAllowed(
permissionUser.RoleName,
TeamPermissionKeys.RequestUplifts,
permissionUser.Overrides))
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
}
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
@ -104,24 +145,16 @@ namespace SeaHaven.Services.Implementation
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
}
Dispatch? dispatch;
if (workOrder.PrimaryDispatchId is int primaryDispatchId)
{
dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
}
else
{
dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken))
.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");
}
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
// 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
// consumed its allowance.
var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");

View file

@ -0,0 +1,230 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public sealed class TeamMemberCreateTransactionTests
{
[Fact]
public async Task Create_MidSequencePersistenceFailure_RollsBackAllMemberRows()
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using (var setup = new SqliteTeamMemberTestDbContext(options))
await setup.Database.EnsureCreatedAsync();
await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true);
ThrowingAfterPersistPermissionDataService failingPermissionData;
await using (var createScope = serviceProvider.CreateAsyncScope())
{
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
failingPermissionData = (ThrowingAfterPersistPermissionDataService)createScope.ServiceProvider
.GetRequiredService<ITeamPermissionOverrideDataService>();
var failure = await Record.ExceptionAsync(() => service.CreateAsync(
ValidRequest(),
Admin(),
CancellationToken.None));
Assert.IsType<InvalidOperationException>(failure);
Assert.Equal("injected mid-sequence failure", failure!.Message);
}
Assert.NotNull(failingPermissionData.RowsAtFailure);
var rowsAtFailure = failingPermissionData.RowsAtFailure!.Value;
Assert.Equal(1, rowsAtFailure.Users);
Assert.Equal(1, rowsAtFailure.Roles);
Assert.Equal(1, rowsAtFailure.UserRoles);
Assert.Equal(2, rowsAtFailure.ServiceAreas);
Assert.Equal(1, rowsAtFailure.PermissionOverrides);
await using var verifyScope = serviceProvider.CreateAsyncScope();
var verify = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(await verify.Users.AsNoTracking().ToListAsync());
Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync());
}
[Fact]
public async Task Create_SuccessfulTransaction_CommitsPendingMemberAndAssociations()
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using (var setup = new SqliteTeamMemberTestDbContext(options))
await setup.Database.EnsureCreatedAsync();
await using var serviceProvider = BuildServiceProvider(connection);
await using (var createScope = serviceProvider.CreateAsyncScope())
{
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
var outcome = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
Assert.True(outcome.Success);
Assert.True(outcome.Member!.PendingRegistration);
Assert.Equal("taylor@example.com", outcome.Member.Email);
}
await using var verifyScope = serviceProvider.CreateAsyncScope();
var verify = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var user = await verify.Users.AsNoTracking().SingleAsync(user => user.Email == "taylor@example.com");
Assert.True(user.PendingRegistration);
var role = await verify.Roles.AsNoTracking().SingleAsync(role => role.Name == "Dispatcher");
Assert.Contains(await verify.UserRoles.AsNoTracking().ToListAsync(), row => row.UserId == user.Id && row.RoleId == role.Id);
var areas = await verify.UserServiceAreas.AsNoTracking()
.Where(area => area.UserId == user.Id)
.Select(area => area.Area)
.OrderBy(area => area)
.ToListAsync();
Assert.Equal(new[] { "East", "West" }, areas);
var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking()
.SingleAsync(permission => permission.UserId == user.Id);
Assert.Equal("deleteSites", permissionOverride.PermissionKey);
}
private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false)
{
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(builder => builder.UseSqlite(connection));
services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqliteTeamMemberTestDbContext(
provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddDataServices();
services.AddBusinessServices(configuration);
if (injectFailure)
{
services.AddScoped<TeamPermissionOverrideDataService>();
services.AddScoped<ITeamPermissionOverrideDataService>(provider =>
new ThrowingAfterPersistPermissionDataService(
provider.GetRequiredService<TeamPermissionOverrideDataService>(),
provider.GetRequiredService<ApplicationDbContext>()));
}
return services.BuildServiceProvider();
}
private static CreateTeamMemberRequestDTO ValidRequest() => new()
{
Name = "Taylor Dispatcher",
Role = "dispatcher",
Color = "#F59E0B",
Email = "taylor@example.com",
Phone = "555-0100",
ServiceAreas = new[] { "east", "West" },
PermissionOverrides = new Dictionary<string, UserPermissionState>
{
["deleteSites"] = UserPermissionState.Allow
}
};
private static ClaimsPrincipal Admin() =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
private sealed class SqliteTeamMemberTestDbContext : ApplicationDbContext
{
public SqliteTeamMemberTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
{
if (index.GetFilter() is not null)
index.SetFilter(null);
}
foreach (var property in builder.Model.GetEntityTypes()
.SelectMany(entity => entity.GetProperties())
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
{
property.ValueGenerated = ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
private sealed class ThrowingAfterPersistPermissionDataService : ITeamPermissionOverrideDataService
{
private readonly ITeamPermissionOverrideDataService _inner;
private readonly ApplicationDbContext _context;
public ThrowingAfterPersistPermissionDataService(
ITeamPermissionOverrideDataService inner,
ApplicationDbContext context)
{
_inner = inner;
_context = context;
}
public (int Users, int Roles, int UserRoles, int ServiceAreas, int PermissionOverrides)? RowsAtFailure { get; private set; }
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken) =>
_inner.GetUserAsync(userId, cancellationToken);
public Task SetOverrideAsync(
string userId,
string permissionKey,
UserPermissionState state,
CancellationToken cancellationToken) =>
_inner.SetOverrideAsync(userId, permissionKey, state, cancellationToken);
public async Task SetOverridesAsync(
string userId,
IReadOnlyDictionary<string, UserPermissionState> overrides,
CancellationToken cancellationToken)
{
await _inner.SetOverridesAsync(userId, overrides, cancellationToken);
RowsAtFailure = (
await _context.Users.CountAsync(cancellationToken),
await _context.Roles.CountAsync(cancellationToken),
await _context.UserRoles.CountAsync(cancellationToken),
await _context.UserServiceAreas.CountAsync(cancellationToken),
await _context.UserPermissionOverrides.CountAsync(cancellationToken));
throw new InvalidOperationException("injected mid-sequence failure");
}
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
_inner.ClearOverridesAsync(userId, cancellationToken);
}
}

View file

@ -0,0 +1,336 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
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;
// An uplift's amount is the NTE increase being requested. Work-order requests store
// that increase in RequestedNTE; vendor-portal requests store the requested NTE total.
// These tests walk each path through the approvals queue, approve and revoke, and the
// queue-wide and per-work-order exposure totals.
public sealed class UpliftAmountPerCreationPathTests
{
private const string DispatcherId = "dispatcher-1";
private const int WorkOrderId = 1;
private const int PrimaryDispatchId = 10;
private const int SecondDispatchId = 11;
private static ApplicationDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static ApprovalsOptions NewOptions() => new()
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
};
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(NewOptions()));
private static UpliftService NewQueueService(ApplicationDbContext context) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
new NoDocumentStorage(),
TimeProvider.System,
Options.Create(NewOptions()));
private static ClaimsPrincipal Admin(string userId = DispatcherId) =>
WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
private static Dispatch DispatchOf(ApplicationDbContext context, int id) =>
context.Dispatches.Single(d => d.Id == id);
// A work order with a 600 NTE on its primary dispatch, and a second dispatch on the
// same work order, also at 600.
private static async Task SeedWorkOrderAsync(ApplicationDbContext context)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Users.Add(new ApplicationUser
{
Id = DispatcherId,
UserName = DispatcherId,
FirstName = "Alex",
LastName = "Dispatcher",
});
var role = new IdentityRole("Admin");
context.Roles.Add(role);
context.UserRoles.Add(new IdentityUserRole<string> { UserId = DispatcherId, RoleId = role.Id });
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.AddRange(
new Dispatch
{
Id = PrimaryDispatchId,
VendorId = 1,
WorkOrderId = WorkOrderId,
NTEAmount = 600m,
DispatchNumber = "DIS-10",
Status = "Completed",
},
new Dispatch
{
Id = SecondDispatchId,
VendorId = 1,
WorkOrderId = WorkOrderId,
NTEAmount = 600m,
DispatchNumber = "DIS-11",
Status = "Completed",
});
context.workOrders.Add(new WorkOrder
{
Id = WorkOrderId,
InternalWONumber = "10000000001",
PrimaryDispatchId = PrimaryDispatchId,
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
});
await context.SaveChangesAsync();
}
// Uses up the work order's auto-approval allowance, so a new work-order request
// waits for approval instead of auto-approving.
private static async Task ConsumeAutoApprovalAllowanceAsync(ApplicationDbContext context)
{
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = SecondDispatchId,
CurrentNTE = 100m,
RequestedNTE = 500m,
Status = "NoApprovalRequired",
RequiredTier = 0,
NotificationStatus = "Sent",
createdby = DispatcherId,
CreatedDate = new DateTime(2026, 1, 2),
});
await context.SaveChangesAsync();
}
private static async Task<int> CreatePendingWorkOrderRequestAsync(ApplicationDbContext context, decimal amount)
{
await ConsumeAutoApprovalAllowanceAsync(context);
var created = await NewWorkOrderUpliftService(context).CreateAsync(
WorkOrderId,
new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra parts" },
Admin(),
CancellationToken.None);
Assert.Equal("pending", created!.Status);
return created.Id;
}
// Stored exactly as the vendor portal writes it: no identity user, and RequestedNTE
// is the requested new NTE total.
private static async Task<int> SeedVendorRequestAsync(
ApplicationDbContext context, int dispatchId, decimal currentNte, decimal requestedTotal)
{
var request = new DispatchUpliftRequest
{
DispatchId = dispatchId,
CurrentNTE = currentNte,
RequestedNTE = requestedTotal,
Status = "Pending",
RequiredTier = 1,
RequestedByVendorName = "Acme HVAC",
NotificationStatus = "Sent",
CreatedDate = new DateTime(2026, 3, 1),
};
context.DispatchUpliftRequests.Add(request);
await context.SaveChangesAsync();
return request.Id;
}
private static Task<WorkOrderUpliftDto?> RevokeAsync(ApplicationDbContext context, int upliftId) =>
NewWorkOrderUpliftService(context).RevokeAsync(
WorkOrderId,
upliftId,
new RevokeWorkOrderUpliftRequestDto { Reason = "Scope reduced" },
Admin(),
CancellationToken.None);
[Fact]
public async Task WorkOrderRequest_QueueShowsTheRequestedIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var pending = await NewQueueService(context)
.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
Assert.Equal(90m, Assert.Single(pending.Items, i => i.Id == upliftId).Delta);
}
[Fact]
public async Task WorkOrderRequest_ApproveAddsIncreaseToNte_RevokeRestoresNte()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var queue = NewQueueService(context);
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
Assert.Equal(690m, approved.NTEAmount);
Assert.Equal(690m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
var revoked = await RevokeAsync(context, upliftId);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
}
[Fact]
public async Task VendorRequest_QueueShowsIncrease_ApproveEndsAtRequestedTotal_RevokeRestoresNte()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var upliftId = await SeedVendorRequestAsync(context, PrimaryDispatchId, currentNte: 600m, requestedTotal: 900m);
var queue = NewQueueService(context);
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
Assert.Equal(300m, Assert.Single(pending.Items).Delta);
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
Assert.Equal(900m, approved.NTEAmount);
Assert.Equal(900m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
var revoked = await RevokeAsync(context, upliftId);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
}
[Fact]
public async Task PendingExposureAndApprovedOnWorkOrder_SumEachRequestsIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
var queue = NewQueueService(context);
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
Assert.Equal(390m, pending.PendingExposureTotal);
Assert.Equal(pending.Items.Sum(i => i.Delta), pending.PendingExposureTotal);
await queue.ApproveAsync(Admin(), workOrderRequestId, null, CancellationToken.None);
await queue.ApproveAsync(Admin(), vendorRequestId, null, CancellationToken.None);
var decided = await queue.ListAsync(Admin(), "Approved", null, 1, 25, CancellationToken.None);
var row = Assert.Single(decided.Items, i => i.Id == workOrderRequestId);
Assert.Equal(390m, row.WorkOrderAdminApprovedTotal);
// The earlier auto-approved work-order request counts its stored increase.
Assert.Equal(500m, row.WorkOrderAutoApprovedTotal);
Assert.Equal(0m, (await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None)).PendingExposureTotal);
}
[Fact]
public async Task AutoApprovedWorkOrderRequest_ConsumesAllowanceByItsRequestedIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var created = await NewWorkOrderUpliftService(context).CreateAsync(
WorkOrderId,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
Admin(),
CancellationToken.None);
Assert.Equal("auto_approved", created!.Status);
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
Assert.Equal(DispatcherId, stored.createdby);
Assert.Equal(400m, stored.RequestedNTE);
Assert.Equal(1000m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
Assert.Equal(400m, await new UpliftDataService(context)
.SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None));
}
[Fact]
public async Task WorkOrderUpliftList_ShowsEachRequestsIncrease()
{
await using var context = CreateContext();
await SeedWorkOrderAsync(context);
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
var uplifts = await NewWorkOrderUpliftService(context)
.ListAsync(WorkOrderId, Admin(), CancellationToken.None);
Assert.Equal(90m, Assert.Single(uplifts!, u => u.Id == workOrderRequestId).Amount);
Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount);
}
[Theory]
[InlineData(DispatcherId, 90, "$90.00")]
[InlineData(null, 900, "$300.00")]
public void NotificationDeltaRow_ShowsTheRequestedIncrease(string? createdBy, int requestedNte, string expectedDelta)
{
var dispatch = new Dispatch { Id = PrimaryDispatchId, DispatchNumber = "DIS-10", NTEAmount = 600m };
var request = new DispatchUpliftRequest
{
DispatchId = PrimaryDispatchId,
CurrentNTE = 600m,
RequestedNTE = requestedNte,
RequiredTier = 1,
createdby = createdBy,
};
// Builds the message content only; nothing is sent.
var (_, body) = UpliftNotificationMessage.BuildInitial(dispatch, request, "Acme HVAC", "https://example.test");
Assert.Contains(
$"<strong>Delta</strong></td><td style='padding:4px 10px;'>{expectedDelta}</td>",
body);
}
[Fact]
public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums()
{
// ToQueryString only builds SQL; it never opens the connection.
using var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer("Server=translation-only;Database=none;Trusted_Connection=True;")
.Options);
var rowSql = context.DispatchUpliftRequests
.Select(UpliftAmount.ForQuery)
.ToQueryString();
var groupedSql = context.DispatchUpliftRequests
.GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery)
.Select(g => new { g.Key.DispatchId, g.Key.Status, Total = g.Sum() })
.ToQueryString();
Assert.Contains("CASE", rowSql);
Assert.Contains("[createdby] IS NULL", rowSql);
Assert.Contains("SUM(CASE", groupedSql);
Assert.Contains("GROUP BY", groupedSql);
}
private sealed class NoDocumentStorage : IVendorDocumentStoragePort
{
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) =>
throw new NotSupportedException();
public void Delete(int vendorId, int dispatchId, string storedFileName)
{
}
}
}

View file

@ -9,6 +9,117 @@ namespace SeaHavenIndustries.Tests;
public sealed class UpliftDataServiceTransactionTests
{
[Theory]
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId'.", true)]
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId_RequestKey'.", false)]
public void IsActiveDispatchIndexViolationMessage_MatchesOnlyTheActiveDispatchIndex(
string message,
bool expected)
{
Assert.Equal(expected, UpliftDataService.IsActiveDispatchIndexViolationMessage(message));
}
[Fact]
public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.EnsureCreatedAsync();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
}, CancellationToken.None);
await data.SaveChangesAsync(CancellationToken.None);
// The database index is the final guard when competing requests pass
// their dispatch-scoped active-request checks.
var exception = await Assert.ThrowsAsync<SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException>(
() => data.ExecuteWorkOrderMutationAsync(
2,
async cancellationToken =>
{
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestedNTE = 1700m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
}, cancellationToken);
await data.SaveChangesAsync(cancellationToken);
return true;
},
CancellationToken.None));
Assert.Equal("An active uplift request already exists for this dispatch.", exception.Message);
Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync());
}
[Fact]
public async Task SaveChangesAsync_RequestKeyConflict_IsNotMappedToActiveDispatchConflict()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.EnsureCreatedAsync();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestKey = "same-key",
RequestedNTE = 1500m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
}, CancellationToken.None);
await data.SaveChangesAsync(CancellationToken.None);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestKey = "same-key",
RequestedNTE = 1700m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
}, CancellationToken.None);
await Assert.ThrowsAsync<DbUpdateException>(
() => data.SaveChangesAsync(CancellationToken.None));
}
[Fact]
public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails()
{

View file

@ -0,0 +1,332 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace SeaHavenIndustries.Tests;
// Relational (SQLite, foreign keys on) coverage for the work-order audit entry written by
// uplift decisions. WorkOrderAuditLogs.WorkOrderId is a required FK, so the in-memory
// provider cannot prove a decision on a dispatch without an owning work order commits.
// The audit goes to the work order the dispatch belongs to (owner, else the one it is
// linked to through DispatchWorkOrders); when none resolves, the decision is still saved
// and no audit row is written.
public sealed class UpliftDecisionAuditRelationalTests
{
private const string AdminId = "admin-1";
public enum Ownership
{
Owned,
Linked,
Unresolvable
}
public enum Decision
{
Approve,
Reject,
RequestChanges
}
[Theory]
[InlineData(Decision.Approve, Ownership.Owned)]
[InlineData(Decision.Approve, Ownership.Linked)]
[InlineData(Decision.Approve, Ownership.Unresolvable)]
[InlineData(Decision.Reject, Ownership.Owned)]
[InlineData(Decision.Reject, Ownership.Linked)]
[InlineData(Decision.Reject, Ownership.Unresolvable)]
[InlineData(Decision.RequestChanges, Ownership.Owned)]
[InlineData(Decision.RequestChanges, Ownership.Linked)]
[InlineData(Decision.RequestChanges, Ownership.Unresolvable)]
public async Task Decision_CommitsAndAuditsResolvedWorkOrder(Decision decision, Ownership ownership)
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
int upliftId;
int? expectedWorkOrderId;
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: null);
}
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
{
var service = new UpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new NoDocumentStorage(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var admin = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, AdminId),
new Claim(ClaimTypes.Role, "Admin")
}, "Test"));
switch (decision)
{
case Decision.Approve:
await service.ApproveAsync(admin, upliftId, null, CancellationToken.None);
break;
case Decision.Reject:
await service.RejectAsync(admin, upliftId, "too high", CancellationToken.None);
break;
case Decision.RequestChanges:
await service.RequestChangesAsync(admin, upliftId, "send a quote", CancellationToken.None);
break;
}
}
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
var expectedStatus = decision switch
{
Decision.Approve => UpliftStatus.Approved,
Decision.Reject => UpliftStatus.Rejected,
_ => UpliftStatus.ChangesRequested
};
Assert.Equal(expectedStatus, saved.Status);
Assert.Equal(AdminId, saved.DecidedByUserId);
if (decision == Decision.Approve)
{
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync(d => d.Id == saved.DispatchId);
Assert.Equal(5_000m, dispatch.NTEAmount);
}
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
if (expectedWorkOrderId is int workOrderId)
{
var audit = Assert.Single(audits);
Assert.Equal(workOrderId, audit.WorkOrderId);
Assert.StartsWith("uplift_", audit.Action);
}
else
{
Assert.Empty(audits);
}
}
[Theory]
[InlineData(Ownership.Linked)]
[InlineData(Ownership.Unresolvable)]
public async Task Expiry_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
int upliftId;
int? expectedWorkOrderId;
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: DateTime.UtcNow.AddHours(-1));
}
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
{
var lifecycle = new UpliftLifecycleService(
new UpliftDataService(context),
new DispatchDataService(context),
null!,
new NoEmailSender(),
Options.Create(new FrontendOptions()),
Options.Create(new ApprovalsOptions()),
TimeProvider.System,
NullLogger<UpliftLifecycleService>.Instance);
Assert.Equal(1, await lifecycle.ExpireDueAsync(CancellationToken.None));
}
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
Assert.Equal(UpliftStatus.Expired, saved.Status);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
if (expectedWorkOrderId is int workOrderId)
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
else
Assert.Empty(audits);
}
[Theory]
[InlineData(Ownership.Linked)]
[InlineData(Ownership.Unresolvable)]
public async Task Escalation_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
int upliftId;
int? expectedWorkOrderId;
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
(upliftId, expectedWorkOrderId) = await SeedAsync(
seed,
ownership,
expiresAt: null,
initialNotificationSentAt: DateTime.UtcNow.AddDays(-3));
}
var emailSender = new NoEmailSender();
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
{
var lifecycle = new UpliftLifecycleService(
new UpliftDataService(context),
new DispatchDataService(context),
null!,
emailSender,
Options.Create(new FrontendOptions()),
Options.Create(new ApprovalsOptions { EscalationRecipients = new[] { "escalate@test.local" } }),
TimeProvider.System,
NullLogger<UpliftLifecycleService>.Instance);
Assert.Equal(1, await lifecycle.EscalateDueAsync(CancellationToken.None));
}
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
Assert.NotNull(saved.EscalatedAt);
Assert.Equal(1, emailSender.Calls);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
if (expectedWorkOrderId is int workOrderId)
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
else
Assert.Empty(audits);
}
private static async Task<(int UpliftId, int? ExpectedWorkOrderId)> SeedAsync(
ApplicationDbContext context,
Ownership ownership,
DateTime? expiresAt,
DateTime? initialNotificationSentAt = null)
{
context.Users.Add(new ApplicationUser
{
Id = AdminId,
UserName = AdminId,
NormalizedUserName = AdminId.ToUpperInvariant(),
Email = "admin@test.local",
NormalizedEmail = "ADMIN@TEST.LOCAL"
});
var vendor = new Vendor { CompanyName = "Vinewood LLC", IsActive = true };
// A second work order keeps ids distinct from the first, so an audit landing on
// the wrong work order cannot pass by coincidence.
var unrelated = new WorkOrder { InternalWONumber = "WO-OTHER", WorkerOrderTitle = "Other" };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-TARGET",
WorkerOrderTitle = "Repair",
LifecycleStatus = LifecycleStatus.Scheduled
};
context.AddRange(vendor, unrelated, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = ownership == Ownership.Owned ? workOrder.Id : null,
DispatchNumber = "DIS-UPLIFT",
Status = "Scheduled",
NTEAmount = 1_000m
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
if (ownership == Ownership.Linked)
{
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id
});
}
var request = new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
CurrentNTE = 1_000m,
RequestedNTE = 5_000m,
VendorReason = "Scope grew",
Status = UpliftStatus.Pending,
RequiredTier = 2,
CreatedDate = DateTime.UtcNow.AddDays(-4),
ExpiresAt = expiresAt,
InitialNotificationSentAt = initialNotificationSentAt,
NotificationStatus = initialNotificationSentAt == null ? "Pending" : "Sent"
};
context.DispatchUpliftRequests.Add(request);
await context.SaveChangesAsync();
return (request.Id, ownership == Ownership.Unresolvable ? null : workOrder.Id);
}
private sealed class NoDocumentStorage : IVendorDocumentStoragePort
{
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
=> throw new NotSupportedException();
public void Delete(int vendorId, int dispatchId, string storedFileName)
=> throw new NotSupportedException();
}
// Records sends without delivering anything.
private sealed class NoEmailSender : IEmailSender
{
public int Calls;
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
Calls++;
return Task.FromResult(true);
}
}
private sealed class SqliteUpliftDecisionTestDbContext : ApplicationDbContext
{
public SqliteUpliftDecisionTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -1,3 +1,4 @@
using System.Globalization;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
@ -55,7 +56,7 @@ public class WorkOrderAdvancedSearchDateRangeTests
return wo;
}
private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new()
private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new()
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = RangeFrom,
@ -149,6 +150,96 @@ public class WorkOrderAdvancedSearchDateRangeTests
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
}
// Payloads the frontend sent before includeDateless existed. They must keep
// their undated rows so this backend can deploy ahead of the client.
[Theory]
[InlineData(2000)] // no range selected: ADVANCED_SEARCH_ALL_WEEKS_FROM
[InlineData(1970)] // pinned Unassigned queue: UNASSIGNED_QUEUE_DATE_FROM
public async Task SearchAsync_AllWeeksWindowWithoutFlag_KeepsOpenUndatedRows(int fromYear)
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 8, 4)),
Wo(2, null, status: LifecycleStatus.Incomplete),
Wo(3, null, status: LifecycleStatus.Completed));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(fromYear, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
Dispatchers = new List<string> { "__unassigned__" },
PageSize = 50
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
}
[Fact]
public async Task SearchAsync_NoDateInputWithoutFlag_KeepsOpenUndatedRows()
{
// The WO# duplicate lookup sends only the search text and paging.
await using var context = CreateContext();
var thisWeek = WorkOrderSearchDateRangeResolver.GetIsoWeekStart(DateOnly.FromDateTime(DateTime.UtcNow));
context.workOrders.AddRange(
Wo(1, thisWeek.ToDateTime(TimeOnly.MinValue)),
Wo(2, null, status: LifecycleStatus.Incomplete),
Wo(3, thisWeek.AddDays(-7).ToDateTime(TimeOnly.MinValue)));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(
new WorkOrderAdvancedSearchQueryDto { PageSize = 100 },
WorkOrderAccountTestHelpers.OrgWideAdmin(),
null);
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
}
[Fact]
public async Task SearchAsync_AllWeeksWindowWithFlagFalse_ExcludesUndatedRows()
{
await using var context = CreateContext();
context.workOrders.AddRange(
Wo(1, new DateTime(2026, 8, 4)),
Wo(2, null, status: LifecycleStatus.Incomplete));
await context.SaveChangesAsync();
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2000, 1, 1),
DateTo = new DateOnly(2099, 12, 31),
IncludeDateless = false,
PageSize = 50
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
}
[Theory]
[InlineData(null, null, null, null, true)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-01", "2099-12-31", true)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "1970-01-01", "2099-12-31", true)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-02", "2099-12-31", false)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2026-09-21", "2026-09-25", false)]
[InlineData(null, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, false)]
[InlineData(false, null, null, null, false)]
[InlineData(true, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, true)]
public void ResolveIncludeDateless_InfersOnlyForNoRangeRequests(
bool? flag,
WorkOrderAdvancedSearchDatePreset? preset,
string? from,
string? to,
bool expected)
{
Assert.Equal(expected, WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
flag,
preset,
from is null ? null : DateOnly.Parse(from, CultureInfo.InvariantCulture),
to is null ? null : DateOnly.Parse(to, CultureInfo.InvariantCulture)));
}
[Fact]
public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows()
{

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
@ -203,6 +204,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
@ -264,6 +267,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
@ -300,6 +305,13 @@ public class WorkOrderBoardCancelServiceTests
FirstName = "Alex",
LastName = "Dispatcher",
});
var adminRole = new IdentityRole("Admin");
seed.Roles.Add(adminRole);
seed.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = adminRole.Id,
});
seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
seed.Dispatches.Add(new Dispatch
{
@ -332,6 +344,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(createContext),
WorkOrderAccountTestHelpers.Resolver(createContext),
new UserDataService(createContext),
new TeamPermissionOverrideDataService(createContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var boardData = new WorkOrderBoardDataService(cancelContext);
@ -345,6 +359,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(cancelContext),
WorkOrderAccountTestHelpers.Resolver(cancelContext),
new UserDataService(cancelContext),
new TeamPermissionOverrideDataService(cancelContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
@ -431,7 +447,6 @@ public class WorkOrderBoardCancelServiceTests
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
throw new NotSupportedException();
public Task<decimal> GetPendingExposureAsync(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();
@ -442,6 +457,7 @@ public class WorkOrderBoardCancelServiceTests
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<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, 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<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();

View file

@ -134,6 +134,72 @@ public class WorkOrderBoardCreateRelationalTests
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
}
[Fact]
public async Task Create_WithVendor_PersistsPrimaryDispatchOwnershipForUpliftResolution()
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using (var context = new SqliteBoardTestDbContext(options))
{
await context.Database.EnsureCreatedAsync();
context.Users.Add(new ApplicationUser
{
Id = "actor-1",
UserName = "actor-1",
NormalizedUserName = "ACTOR-1",
Email = "actor@test.local",
NormalizedEmail = "ACTOR@TEST.LOCAL"
});
await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
await context.SaveChangesAsync();
}
WorkOrderBoardRowDto result;
await using (var context = new SqliteBoardTestDbContext(options))
{
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var service = new WorkOrderBoardCreateService(
boardData,
new WorkOrderBoardMutationDataService(context),
new WorkOrderBoardService(boardData, resolver),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context));
result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
VendorId = 5
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
}
// SH-393: fresh context, committed state only. The primary dispatch must be owned
// by the work order so the uplift create/read scope resolves it.
await using var verify = new SqliteBoardTestDbContext(options);
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync();
Assert.Equal(result.PrimaryDispatchId, dispatch.Id);
Assert.Equal(result.Id, dispatch.WorkOrderId);
var upliftDispatch = await new UpliftDataService(verify).GetUpliftDispatchForWorkOrderAsync(
result.Id,
result.PrimaryDispatchId,
CancellationToken.None);
Assert.Equal(dispatch.Id, upliftDispatch?.Id);
}
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
{
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)

View file

@ -451,6 +451,57 @@ public class WorkOrderBoardPendingUpliftTests
Assert.Equal(1500m, dto.UpliftSummary.Amount);
}
[Fact]
public async Task GetBoardRows_VendorPortalUplift_SummaryAmountIsTheRequestedIncrease()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
NTEAmount = 600m,
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
SiteCode = "BK5"
});
// A vendor-portal request (no identity user) stores the requested NTE total.
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
CurrentNTE = 600m,
RequestedNTE = 900m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var result = await new WorkOrderBoardDataService(context).GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
false,
null,
null));
var dto = WorkOrderBoardService.MapRawRow(Assert.Single(result.ScheduledRows), DateTime.UtcNow);
Assert.Equal(300m, dto.UpliftSummary!.Amount);
}
[Fact]
public async Task GetBoardRows_ApprovedUplift_DoesNotBlock()
{

View file

@ -0,0 +1,352 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
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" });
// SH-327: uplift creation checks the actor's RequestUplifts permission.
var adminRole = new IdentityRole("Admin");
context.Roles.Add(adminRole);
context.Users.Add(new ApplicationUser { Id = "actor-1", UserName = "actor-1" });
context.UserRoles.Add(new IdentityUserRole<string> { UserId = "actor-1", RoleId = adminRole.Id });
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),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
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());
}
}

View file

@ -1,9 +1,11 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@ -37,6 +39,8 @@ public sealed class WorkOrderUpliftServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(NewOptions()));
}
@ -47,6 +51,7 @@ public sealed class WorkOrderUpliftServiceTests
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
ApplicationDbContext context,
WorkOrderType type = WorkOrderType.PM,
string role = "Admin",
int? primaryDispatchId = 10)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
@ -57,6 +62,13 @@ public sealed class WorkOrderUpliftServiceTests
FirstName = "Alex",
LastName = "Dispatcher",
});
var identityRole = new IdentityRole(role);
context.Roles.Add(identityRole);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = identityRole.Id,
});
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
@ -124,6 +136,22 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
}
[Fact]
public async Task CreateAsync_CurrentDatabaseAdmin_RemainsUnrestricted()
{
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 },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
CancellationToken.None);
Assert.NotNull(created);
}
[Fact]
public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch()
{
@ -174,6 +202,109 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(workOrder.Id, audit.WorkOrderId);
}
[Fact]
public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Scheduler");
var service = NewService(context);
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
Dispatcher(),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Fact]
public async Task CreateAsync_MissingDatabaseUser_DeniesEvenWithAdminClaim()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.UserRoles.RemoveRange(context.UserRoles.Where(userRole => userRole.UserId == "dispatcher-1"));
context.Users.Remove(context.Users.Single(user => user.Id == "dispatcher-1"));
await context.SaveChangesAsync();
var service = NewService(context);
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
Dispatcher("dispatcher-1"),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Theory]
[InlineData("Dispatcher", true)]
[InlineData("Scheduler", false)]
public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Theory]
[InlineData("Scheduler", UserPermissionState.Allow, true)]
[InlineData("Dispatcher", UserPermissionState.Deny, false)]
public async Task CreateAsync_AppliesExplicitRequestPermissionOverride(
string role,
UserPermissionState overrideState,
bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = "dispatcher-1",
PermissionKey = TeamPermissionKeys.RequestUplifts,
State = overrideState,
});
await context.SaveChangesAsync();
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Fact]
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
{
@ -289,7 +420,21 @@ public sealed class WorkOrderUpliftServiceTests
{
await using var context = CreateContext();
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
{
Id = 100,