Merge remote-tracking branch 'origin/dev' into feat/ab/sh-326-team-member

This commit is contained in:
Alexandre Brandizzi 2026-09-17 13:49:21 -03:00
commit bc88ef0577
66 changed files with 12780 additions and 130 deletions

View file

@ -0,0 +1,54 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DashboardControllerTests
{
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
private static DashboardController NewController(Mock<IDashboardService> service) =>
new(service.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
// Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and
// DashboardStatsDTO already carried these three counts; the regression this
// pins is the controller's anonymous response silently dropping them, which
// is what left the tiles with nothing to read.
[Fact]
public async Task GetStats_SerialisesKpiCountsOnWireObject()
{
var service = new Mock<IDashboardService>();
service.Setup(s => s.GetStatsAsync(
It.IsAny<ClaimsPrincipal>(),
It.IsAny<DashboardStatsQueryDTO>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new DashboardStatsDTO
{
ScheduledTomorrow = 3,
PendingUplifts = 5,
AvetaPending = 7
});
var result = await NewController(service).GetStats(
new DashboardStatsQueryDTO(), CancellationToken.None);
var body = result.Should().BeOfType<OkObjectResult>().Subject.Value!;
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
((int)Prop(body, "pendingUplifts")).Should().Be(5);
((int)Prop(body, "avetaPending")).Should().Be(7);
}
}

View file

@ -99,6 +99,55 @@ public class DashboardServiceTests
stats.Completed.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly()
{
using var ctx = NewContext();
var today = DashboardBusinessTime.Today();
var tomorrow = today.AddDays(1);
// Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow.
ctx.workOrders.Add(new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue)
});
// Aveta-pending tile: Aveta-required order in the today/tomorrow window
// with no Aveta attachment. Its Dispatch anchors the uplift requests below.
var avetaOrder = new WorkOrder
{
AccountId = 1,
AvetaRequired = true,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
};
ctx.workOrders.Add(avetaOrder);
ctx.SaveChanges();
var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false };
ctx.Set<Dispatch>().Add(dispatch);
ctx.SaveChanges();
// Pending-uplifts tile: only Status == "Pending" is an outstanding uplift
// awaiting an approval decision. This test pins the behaviour the code
// ships today: a "ChangesRequested" request has been sent back to the
// vendor, so it is NOT counted. Whether pendingUplifts should also include
// ChangesRequested is an open product call, not a settled review decision.
ctx.Set<DispatchUpliftRequest>().AddRange(
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false },
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
stats.ScheduledTomorrow.Should().Be(1);
stats.AvetaPending.Should().Be(1);
stats.PendingUplifts.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_MissingScopeFailsClosed()
{
@ -221,11 +270,13 @@ public class DashboardServiceTests
1,
CancellationToken.None);
result.TotalDispatchers.Should().Be(1);
result.Items.Should().ContainSingle();
result.Items[0].DispatcherName.Should().Be("Ada One");
result.TotalDispatchers.Should().Be(2);
result.Items.Select(item => item.DispatcherName)
.Should().ContainInOrder("Ada One", "Ben Two");
result.Items[0].TotalCount.Should().Be(2);
result.Items[0].OpenCount.Should().Be(1);
result.Items[1].TotalCount.Should().Be(0);
result.Items[1].OpenCount.Should().Be(0);
result.PageSize.Should().Be(10);
var adminResult = await NewService(ctx).GetWorkloadAsync(
@ -309,11 +360,17 @@ public class DashboardServiceTests
ctx.Locations.AddRange(
new Locations { Id = 1, State = "NY" },
new Locations { Id = 2, State = "ca" },
new Locations { Id = 3, State = "XX" });
new Locations { Id = 3, State = "XX" },
new Locations { Id = 4, State = "indiana" },
new Locations { Id = 5, State = "NEW YORK" },
new Locations { Id = 6, State = "California" });
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, LocationId = 1 },
new WorkOrder { AccountId = 1, LocationId = 2 },
new WorkOrder { AccountId = 1, LocationId = 3 },
new WorkOrder { AccountId = 1, LocationId = 4 },
new WorkOrder { AccountId = 1, LocationId = 5 },
new WorkOrder { AccountId = 1, LocationId = 6 },
new WorkOrder { AccountId = 1, LocationId = null });
await ctx.SaveChangesAsync();
@ -322,7 +379,28 @@ public class DashboardServiceTests
result.Items.Select(row => row.Region).Should().Equal(
"East", "Central", "West", "California", "Unmapped/Other");
result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2);
// NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California;
// "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not
// fall through to Unmapped/Other (SH-348 review).
result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2);
}
[Fact]
public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket()
{
foreach (var code in UsStateCodes.All)
{
var byCode = DashboardRegions.Resolve(code);
byCode.Should().NotBe(
"Unmapped/Other",
$"state code {code} must belong to a canonical region");
var fullName = UsStateCodes.ExpandStorageValues(new[] { code })
.First(value => value != code);
DashboardRegions.Resolve(fullName).Should().Be(
byCode,
$"the full-name storage form of {code} must resolve to the same region as the code");
}
}
[Fact]
@ -368,6 +446,275 @@ public class DashboardServiceTests
result.Items[0].RescheduleRate.Should().Be(50);
}
[Fact]
public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" };
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
stats.Total.Should().Be(2);
stats.Open.Should().Be(2);
}
[Fact]
public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var service = NewService(ctx);
var selected = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
selected.Total.Should().Be(3);
selected.Open.Should().Be(3);
var mine = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "mine" },
CancellationToken.None);
mine.Total.Should().Be(1);
mine.Completed.Should().Be(1);
var all = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
all.Total.Should().Be(4);
}
[Fact]
public async Task GetStatsAsync_ManagerSelectionScopesStats()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "manager-1", "Manager"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
stats.Total.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_SchedulerSelectionScopesStats()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "scheduler-1", "Scheduler"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
stats.Total.Should().Be(1);
}
[Fact]
public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed()
{
using var ctx = NewContext();
var service = NewService(ctx);
var user = AccountUser(1, "tech-1", "Technician");
var statsAct = () => service.GetStatsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var statsException = await statsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
statsException.Which.Code.Should().Be("Forbidden");
var workloadAct = () => service.GetWorkloadAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var workloadException = await workloadAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
workloadException.Which.Code.Should().Be("Forbidden");
var performanceAct = () => service.GetPerformanceAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var performanceException = await performanceAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
performanceException.Which.Code.Should().Be("Forbidden");
var regionsAct = () => service.GetRegionsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var regionsException = await regionsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
regionsException.Which.Code.Should().Be("Forbidden");
var trendAct = () => service.GetTrendAsync(
user, new DashboardTrendQueryDTO(), CancellationToken.None);
var trendException = await trendAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
trendException.Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows()
{
using var ctx = NewContext();
ctx.Roles.Add(new IdentityRole
{
Id = "dispatcher-role",
Name = "Dispatcher",
NormalizedName = "DISPATCHER"
});
ctx.Users.AddRange(
new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" },
new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" });
ctx.UserRoles.AddRange(
new IdentityUserRole<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
new IdentityUserRole<string> { UserId = "dispatcher-2", RoleId = "dispatcher-role" });
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" },
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" });
ctx.SaveChanges();
var workload = await NewService(ctx).GetWorkloadAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
1,
CancellationToken.None);
workload.TotalDispatchers.Should().Be(2);
workload.Items.Should().HaveCount(2);
workload.Items[0].DispatcherId.Should().Be("dispatcher-1");
workload.Items[0].TotalCount.Should().Be(2);
workload.Items[0].OpenCount.Should().Be(2);
workload.Items[1].DispatcherId.Should().Be("dispatcher-2");
workload.Items[1].TotalCount.Should().Be(0);
workload.Items[1].OpenCount.Should().Be(0);
workload.Page.Should().Be(1);
workload.PageSize.Should().Be(10);
var performance = await NewService(ctx).GetPerformanceAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
1,
CancellationToken.None);
performance.TotalDispatchers.Should().Be(2);
performance.Items.Should().HaveCount(2);
performance.Items[0].DispatcherId.Should().Be("dispatcher-1");
performance.Items[0].AssignedCount.Should().Be(2);
performance.Items[1].DispatcherId.Should().Be("dispatcher-2");
performance.Items[1].AssignedCount.Should().Be(0);
performance.Items[1].CompletedCount.Should().Be(0);
}
[Fact]
public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts()
{
using var ctx = NewContext();
ctx.Roles.Add(new IdentityRole
{
Id = "dispatcher-role",
Name = "Dispatcher",
NormalizedName = "DISPATCHER"
});
ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" });
ctx.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = "dispatcher-role"
});
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled
});
ctx.SaveChanges();
var result = await NewService(ctx).GetPerformanceAsync(
AccountUser(1),
new DashboardStatsQueryDTO(),
1,
CancellationToken.None);
result.Items.Should().ContainSingle();
result.Items[0].AssignedCount.Should().Be(3);
result.Items[0].CompletedCount.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
},
new WorkOrder { AccountId = 1, Status = "In Progress" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Unparseable" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
stats.StatusDistribution.Should().HaveCount(4);
stats.StatusDistribution
.Select(bucket => (bucket.Status, bucket.Count))
.Should().BeEquivalentTo(new[]
{
(Status: "Completed", Count: 2),
(Status: "In Progress", Count: 1),
(Status: "Scheduled", Count: 1),
(Status: "Unknown", Count: 1)
});
stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5);
}
[Fact]
public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders()
{
using var ctx = NewContext();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
stats.StatusDistribution.Should().BeEmpty();
}
[Fact]
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
{

View file

@ -26,7 +26,7 @@ public class LocationControllerTests
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
@ -49,7 +49,7 @@ public class LocationControllerTests
public async Task GetLocationList_PassesStatesFilterToService()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO>(),
@ -58,7 +58,7 @@ public class LocationControllerTests
PageSize = 10
});
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.VerifyAll();
@ -68,13 +68,13 @@ public class LocationControllerTests
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ThrowsAsync(new ValidationException(new[]
{
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
}));
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None);
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
@ -200,4 +200,90 @@ public class LocationControllerTests
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
}
[Fact]
public async Task GetLocationList_ForwardsSortParamsToService()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"))
.ReturnsAsync(new PagedResult<LocationDTO> { Items = new List<LocationDTO>(), TotalCount = 0, Page = 1, PageSize = 10 });
await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None);
service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"), Times.Once);
}
[Fact]
public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ThrowsAsync(new ValidationException(new[]
{
new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.")
}));
var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Validation Error");
response.Message.Should().Contain("sortBy");
}
[Fact]
public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), null, null))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO>
{
new()
{
Id = 1,
Name = "STL8",
Address1 = "9 River Rd",
City = "St. Louis",
State = "MO",
Zip = "63101",
PhoneNumber = "555-0100",
Email = "poc@example.com",
AccountId = 3,
AccountName = "Acme Co",
Contacts = new List<SiteContactResponseDTO>
{
new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" },
new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" }
}
}
},
TotalCount = 1,
Page = 1,
PageSize = 10
});
var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value);
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field");
row.GetProperty("Address").GetString().Should().Be("9 River Rd");
row.GetProperty("City").GetString().Should().Be("St. Louis");
row.GetProperty("State").GetString().Should().Be("MO");
row.GetProperty("ZipCode").GetString().Should().Be("63101");
row.GetProperty("Phone").GetString().Should().Be("555-0100");
row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com");
row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field");
row.GetProperty("AccountId").GetInt32().Should().Be(3);
row.GetProperty("ClientName").GetString().Should().Be("Acme Co");
row.GetProperty("AccountName").GetString().Should().Be("Acme Co");
var contacts = row.GetProperty("Contacts");
contacts.GetArrayLength().Should().Be(2);
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
}
}

View file

@ -86,4 +86,129 @@ public class LocationDataServiceTests
totalCount.Should().Be(2);
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
}
private static async Task SeedRegistryAsync(ApplicationDbContext ctx)
{
ctx.Accounts.AddRange(
new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false },
new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false });
ctx.Locations.AddRange(
new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 },
new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 },
new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 },
new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 });
ctx.Contacts.AddRange(
new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" },
new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" },
new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" },
new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true },
new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" });
await ctx.SaveChangesAsync();
}
[Theory]
[InlineData("STL", new[] { 10 }, "site code")]
[InlineData("Borealis", new[] { 11, 13 }, "client name")]
[InlineData("Prairie", new[] { 11 }, "street address")]
[InlineData("Miami", new[] { 13 }, "city")]
public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField)
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, totalCount) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, term, null, CancellationToken.None);
totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}");
items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds);
}
[Theory]
[InlineData("code", 11, 12, 13, 10)]
[InlineData("client", 10, 12, 11, 13)]
[InlineData("address", 13, 11, 12, 10)]
[InlineData("city", 11, 12, 13, 10)]
[InlineData("state", 13, 12, 10, 11)]
[InlineData("poc", 13, 12, 10, 11)]
public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak(
string sortBy, params int[] expectedIds)
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc");
items.Select(l => l.Id).Should().ContainInOrder(expectedIds);
}
[Fact]
public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc");
// DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must
// sort last; including the deleted row ("Removed Poc") would instead place it second.
items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11);
}
[Fact]
public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc");
items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 });
}
[Fact]
public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways");
items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8");
}
[Fact]
public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages()
{
await using var ctx = NewContext();
ctx.Locations.AddRange(
new Locations { Id = 20, Name = "HOU1", City = "Dallas" },
new Locations { Id = 21, Name = "AUS3", City = "Dallas" },
new Locations { Id = 22, Name = "ELP4", City = "Dallas" });
await ctx.SaveChangesAsync();
var service = new LocationDataService(ctx);
var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc");
var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc");
totalCount.Should().Be(3);
page1.Select(l => l.Id).Should().ContainInOrder(20, 21);
page2.Select(l => l.Id).Should().ContainInOrder(22);
}
[Fact]
public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co");
}
}

View file

@ -131,8 +131,8 @@ public class LocationServiceTests
{
var data = new Mock<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
.ReturnsAsync((new List<Locations>(), 0));
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
@ -149,8 +149,8 @@ public class LocationServiceTests
{
var data = new Mock<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
.ReturnsAsync((new List<Locations>(), 0));
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
@ -173,14 +173,14 @@ public class LocationServiceTests
&& e.ErrorMessage.Contains("ZZ")
&& e.ErrorMessage.Contains("QQ")
&& !e.ErrorMessage.Contains("TX"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((new List<Locations>(), 0));
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
@ -533,4 +533,97 @@ public class LocationServiceTests
again.Should().BeFalse();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
{
var data = new Mock<ILocationDataService>();
string? capturedSort = "sentinel";
string? capturedDirection = "sentinel";
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
{
capturedSort = sortBy;
capturedDirection = sortDirection;
})
.ReturnsAsync((new List<Locations>(), 0));
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC ");
capturedSort.Should().Be("poc");
capturedDirection.Should().Be("desc");
}
[Theory]
[InlineData(null, null)]
[InlineData("", " ")]
public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults(
string? sortBy,
string? sortDirection)
{
var data = new Mock<ILocationDataService>();
string? capturedSort = "sentinel";
string? capturedDirection = "sentinel";
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
{
capturedSort = s;
capturedDirection = d;
})
.ReturnsAsync((new List<Locations>(), 0));
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection);
capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering");
capturedDirection.Should().Be("asc");
}
[Theory]
[InlineData("rating")]
[InlineData("password; drop table locations")]
public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy)
{
var data = new Mock<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e =>
e.PropertyName == "sortBy"
&& e.ErrorMessage.Contains("code")
&& e.ErrorMessage.Contains("poc"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Theory]
[InlineData("ascending")]
[InlineData("DESC; drop table locations")]
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
{
var data = new Mock<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e =>
e.PropertyName == "sortDirection"
&& e.ErrorMessage.Contains("asc")
&& e.ErrorMessage.Contains("desc"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows()
{
using var ctx = NewContext();
SeedAccount(ctx, 9, "Acme Co");
SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9;
await ctx.SaveChangesAsync();
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
var row = page.Items.Should().ContainSingle().Subject;
row.AccountId.Should().Be(9);
row.AccountName.Should().Be("Acme Co");
}
}

View file

@ -430,7 +430,7 @@ public class LocationSiteContactsTests
public async Task List_LoadsPageContactsInSingleBatchedRead()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((new List<Locations>
{
new() { Id = 1, Name = "One" },

View file

@ -0,0 +1,185 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class ServicesRegistryControllerTests
{
private static ServicesRegistryController NewController(Mock<IServicesRegistryService> service, params string[] roles)
{
var controller = new ServicesRegistryController(service.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"))
}
}
};
return controller;
}
private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) =>
(result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto;
[Fact]
public async Task GetAll_ReturnsOkWithServices()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ServiceDto> { new() { Id = 1, Name = "Leak", IsActive = true } });
var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
(ok.Value as IEnumerable<ServiceDto>).Should().ContainSingle(s => s.Name == "Leak");
}
[Fact]
public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException(
"WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency."));
var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid");
ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency.");
}
[Fact]
public async Task GetById_WhenMissing_ReturnsNotFoundWithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.GetByIdAsync(9, It.IsAny<CancellationToken>()))
.ReturnsAsync((ServiceDto?)null);
var result = await NewController(service, "Admin").GetById(9, CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
ErrorValue(notFound)!.Code.Should().Be("NotFound");
}
[Fact]
public async Task Create_PassesCallerAndReturnsCreated()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true });
var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
var created = result.Should().BeOfType<ObjectResult>().Subject;
created.StatusCode.Should().Be(StatusCodes.Status201Created);
(created.Value as ServiceDto)!.Id.Should().Be(42);
service.Verify(s => s.CreateAsync(
It.Is<ClaimsPrincipal>(p => p.IsInRole("Scheduler")),
It.Is<ServiceInput>(i => i.Name == "Leak"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Create_WhenForbidden_Returns403WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."));
var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenDuplicateName_Returns400WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."));
var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
ErrorValue(bad)!.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Update_ReturnsOkWithUpdatedService()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 7, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" });
var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
(ok.Value as ServiceDto)!.Name.Should().Be("Renamed");
}
[Fact]
public async Task Update_WhenNotFound_Returns404WithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
ErrorValue(notFound)!.Code.Should().Be("NotFound");
}
[Fact]
public async Task Deactivate_ReturnsOk()
{
var service = new Mock<IServicesRegistryService>();
var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.Verify(s => s.DeactivateAsync(
It.Is<ClaimsPrincipal>(p => p.IsInRole("Admin")), 3, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Deactivate_WhenForbidden_Returns403WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 3, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."));
var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Deactivate_WhenNotFound_Returns404WithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
}

View file

@ -0,0 +1,396 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
// Global Services registry behavior. The registry has no tenant scope
// (no AccountId), authorization is enforced at the service boundary, and the
// only allowed supported work-order types are PM, Reactive, and Emergency.
public class ServicesRegistryServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ServicesRegistryService NewService(ApplicationDbContext ctx) =>
new(new ServicesRegistryDataService(ctx));
private static ClaimsPrincipal Principal(params string[] roles) =>
new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"));
private static ClaimsPrincipal Admin() => Principal("Admin");
private static ClaimsPrincipal Scheduler() => Principal("Scheduler");
private static ServiceInput Input(
string name = "Leak",
string? trade = "Plumbing & Water Systems",
string? iconKey = "plumbing-water-systems",
bool requiresDoc = false,
int? templateId = null,
List<WorkOrderType>? types = null) => new()
{
Name = name,
Trade = trade,
IconKey = iconKey,
RequiresCompletionDocument = requiresDoc,
CompletionDocTemplateId = templateId,
SupportedWorkOrderTypes = types ?? new List<WorkOrderType>
{
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
}
};
private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) =>
new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted };
[Fact]
public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes()
{
using var ctx = NewContext();
var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None);
dto.Id.Should().BeGreaterThan(0);
dto.Name.Should().Be("Leak Detection");
dto.Trade.Should().Be("Plumbing & Water Systems");
dto.IconKey.Should().Be("plumbing-water-systems");
dto.IsActive.Should().BeTrue("new services are always active");
dto.CompletionDocTemplate.Should().BeNull();
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency });
var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single();
row.NormalizedName.Should().Be("leak detection");
}
[Fact]
public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes()
{
using var ctx = NewContext();
var input = Input(types: null);
input.SupportedWorkOrderTypes = null;
var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[]
{
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
});
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name)
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Subject.Single();
ex.Code.Should().Be("NameRequired");
}
[Fact]
public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TradeInvalid");
}
[Theory]
[InlineData(" hvac ")]
[InlineData("HVAC")]
[InlineData("hvac")]
public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade)
{
using var ctx = NewContext();
var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None);
dto.Trade.Should().Be("HVAC");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey)
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("IconKeyInvalid");
}
[Fact]
public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing()
{
using var ctx = NewContext();
var service = NewService(ctx);
await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter()
{
using var ctx = NewContext();
var service = NewService(ctx);
// Distinct actors with different role sets still share one global registry.
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
var all = await service.GetAllAsync(null, null, CancellationToken.None);
all.Should().ContainSingle(s => s.Id == first.Id);
typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions");
}
[Fact]
public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid()
{
using var ctx = NewContext();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
ctx.CompletionDocTemplates.Add(Template("Inactive", active: false));
ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true));
ctx.SaveChanges();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
}
[Fact]
public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary()
{
using var ctx = NewContext();
var template = Template("Completion Packet");
ctx.CompletionDocTemplates.Add(template);
ctx.SaveChanges();
var dto = await NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None);
dto.RequiresCompletionDocument.Should().BeTrue();
dto.CompletionDocTemplate.Should().NotBeNull();
dto.CompletionDocTemplate!.Id.Should().Be(template.Id);
dto.CompletionDocTemplate.Name.Should().Be("Completion Packet");
}
[Fact]
public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink()
{
using var ctx = NewContext();
var template = Template("Completion Packet");
ctx.CompletionDocTemplates.Add(template);
ctx.SaveChanges();
var dto = await NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None);
dto.RequiresCompletionDocument.Should().BeFalse();
dto.CompletionDocTemplate.Should().BeNull();
ctx.Services.Single().CompletionDocTemplateId.Should().BeNull();
}
[Theory]
[InlineData(WorkOrderType.PO)]
[InlineData(WorkOrderType.Project)]
[InlineData(WorkOrderType.Inspection)]
[InlineData(WorkOrderType.AddOn)]
[InlineData(WorkOrderType.Other)]
public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported)
{
using var ctx = NewContext();
var input = Input(types: new List<WorkOrderType> { WorkOrderType.PM, unsupported });
var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
}
[Fact]
public async Task Update_RewritesFieldsButPreservesActiveState()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
var updated = await service.UpdateAsync(
Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None);
updated.Name.Should().Be("Leak Repair");
updated.Trade.Should().Be("HVAC");
updated.IconKey.Should().Be("hvac");
updated.IsActive.Should().BeFalse("update preserves the persisted active state");
}
[Fact]
public async Task Update_WhenAdminRequestsActiveState_ReactivatesService()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
var input = Input("Inactive service");
input.IsActive = true;
var updated = await service.UpdateAsync(Admin(), created.Id, input, CancellationToken.None);
updated.IsActive.Should().BeTrue();
ctx.Services.Single().IsActive.Should().BeTrue();
}
[Fact]
public async Task Update_WhenSchedulerRequestsActiveState_ThrowsForbidden()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
var input = Input("Inactive service");
input.IsActive = true;
var act = () => service.UpdateAsync(Scheduler(), created.Id, input, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().IsActive.Should().BeFalse();
}
[Fact]
public async Task Update_WhenNotFound_ThrowsNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
}
[Fact]
public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName()
{
using var ctx = NewContext();
var service = NewService(ctx);
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None);
var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().Name.Should().Be("Leak");
}
[Fact]
public async Task Deactivate_SoftStateChangeOnly_PreservesRow()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
ctx.Services.Should().HaveCount(1, "deactivation never hard deletes");
var byId = await service.GetByIdAsync(created.Id, CancellationToken.None);
byId.Should().NotBeNull();
byId!.IsActive.Should().BeFalse();
}
[Fact]
public async Task Deactivate_WhenScheduler_ThrowsForbidden()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().IsActive.Should().BeTrue();
}
[Fact]
public async Task Deactivate_WhenNotFound_ThrowsNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
}
[Fact]
public async Task GetAll_FiltersByActiveStateAndWorkOrderType()
{
using var ctx = NewContext();
var service = NewService(ctx);
var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List<WorkOrderType> { WorkOrderType.PM }), CancellationToken.None);
var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List<WorkOrderType> { WorkOrderType.Reactive }), CancellationToken.None);
await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None);
(await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { pm.Id });
(await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { reactive.Id });
(await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { pm.Id });
(await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None))
.Should().BeEmpty();
}
[Fact]
public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid()
{
using var ctx = NewContext();
var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
}
[Fact]
public async Task GetById_WhenMissing_ReturnsNull()
{
using var ctx = NewContext();
(await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull();
}
}

View file

@ -52,6 +52,56 @@ public class UpliftControllerTests
envelope.Status.Should().Be("Success");
}
[Fact]
public async Task List_PassesStatusAndTierFiltersToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpliftListResultDTO());
var controller = NewController(service);
await controller.List("Approved", 2, 3, 50);
service.Verify(
x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task List_ReturnsQueueContractFieldsInEnvelope()
{
var service = new Mock<IUpliftService>();
var item = new UpliftListItemDTO
{
Id = 7,
WorkOrderId = 11,
WorkOrderNumber = "WO-77",
WorkOrderSite = "SITE-EAST",
WorkOrderService = "HVAC",
RequestedByName = "Gateway",
WorkOrderAutoApprovedTotal = 150m,
WorkOrderAdminApprovedTotal = 300m,
WorkOrderApprovedExposureTotal = 450m
};
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpliftListResultDTO { Total = 1, Page = 1, PageSize = 25, Items = new[] { item } });
var controller = NewController(service);
var result = await controller.List();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var data = envelope.Data as UpliftListResultDTO;
var returned = data!.Items.Should().ContainSingle().Subject;
returned.WorkOrderNumber.Should().Be("WO-77");
returned.WorkOrderSite.Should().Be("SITE-EAST");
returned.WorkOrderService.Should().Be("HVAC");
returned.RequestedByName.Should().Be("Gateway");
returned.WorkOrderApprovedExposureTotal.Should().Be(450m);
}
[Fact]
public async Task Approve_NotFound_Returns404()
{
@ -67,6 +117,49 @@ public class UpliftControllerTests
nf.Value.Should().BeOfType<Response>();
}
[Fact]
public async Task Revoke_WithReason_DelegatesToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
"Policy change",
It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" });
var controller = NewController(service, "Admin");
var result = await controller.Revoke(
7,
new UpliftController.DecisionRequest { Note = "Policy change" });
result.Should().BeOfType<OkObjectResult>();
service.Verify(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
"Policy change",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Revoke_WithoutReason_ReturnsBadRequest()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
string.Empty,
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("reason required"));
var controller = NewController(service, "Admin");
var result = await controller.Revoke(7, null);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
{

View file

@ -0,0 +1,723 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Moq;
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;
// Behavior tests for the approval queue read contract. These exercise the real
// service + data-service layers against an in-memory DbContext so that queue
// ordering, filters, flattened work-order fields, exposure aggregation, and
// per-tier read authorization are validated through the public contract.
public sealed class UpliftQueueReadTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ApprovalsOptions NewOptions() => new()
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
Tier1NotificationRecipients = new[] { "tier1@example.com" },
Tier2NotificationRecipients = new[] { "tier2@example.com" },
EscalationRecipients = new[] { "escalate@example.com" }
};
private static UpliftService NewService(ApplicationDbContext context) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
Mock.Of<IVendorDocumentStoragePort>(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(NewOptions()));
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 DispatchUpliftRequest Request(
Dispatch dispatch,
string status,
DateTime created,
DateTime? decided = null,
int tier = 1,
decimal requested = 100m,
string? reason = null,
string? requestedBy = null,
string? createdBy = null,
decimal? currentNte = null) => new()
{
DispatchId = dispatch.Id,
Status = status,
CreatedDate = created,
DecidedAt = decided,
RequiredTier = tier,
RequestedNTE = requested,
CurrentNTE = currentNte,
VendorReason = reason,
RequestedByVendorName = requestedBy,
createdby = createdBy,
NotificationStatus = "Pending"
};
private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync(
ApplicationDbContext context,
string number,
string site,
string service,
string vendorName = "Gateway")
{
var vendor = new Vendor { CompanyName = vendorName, IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = number,
WorkerOrderNumber = $"legacy-{number}",
SiteCode = site,
Service = service,
WorkerOrderTitle = "Repair"
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
return (vendor, workOrder);
}
private static async Task<Dispatch> SeedDispatchAsync(
ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number)
{
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
DispatchNumber = number,
Status = "Completed",
NTEAmount = 1000m
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
return dispatch;
}
// --- Ordering ---
[Fact]
public async Task List_PendingStatus_OrdersOldestRequestFirst()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 3)),
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.RequestedAt).Should().Equal(
new DateTime(2026, 3, 1),
new DateTime(2026, 3, 2),
new DateTime(2026, 3, 3));
}
[Fact]
public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)),
Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)),
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.DecidedAt).Should().Equal(
new DateTime(2026, 3, 20),
new DateTime(2026, 3, 15),
new DateTime(2026, 3, 10));
}
[Fact]
public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.RequestedAt).Should().ContainInOrder(
new DateTime(2026, 3, 10),
new DateTime(2026, 3, 1));
}
// --- Filters and scoping ---
[Fact]
public async Task List_StatusAndTierFilters_Combine()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2),
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None);
result.Total.Should().Be(1);
result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending");
}
[Fact]
public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
var deletedDispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
DispatchNumber = "DIS-DELETED",
Status = "Completed",
IsDeleted = true
};
context.Dispatches.Add(deletedDispatch);
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 3, 2),
IsDeleted = true,
RequiredTier = 1,
RequestedNTE = 100m
},
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Total.Should().Be(1);
result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1));
}
// --- Flattened queue fields ---
[Fact]
public async Task List_MapsFlattenedWorkOrderAndRequesterFields()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(Request(
dispatch, "Pending", new DateTime(2026, 3, 2),
tier: 2, requested: 400m, reason: "Scope grew",
requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Should().ContainSingle().Subject;
item.WorkOrderId.Should().Be(workOrder.Id);
item.WorkOrderNumber.Should().Be("WO-77");
item.WorkOrderSite.Should().Be("SITE-EAST");
item.WorkOrderService.Should().Be("HVAC");
item.VendorCompanyName.Should().Be("Gateway");
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);
item.Status.Should().Be("Pending");
}
[Fact]
public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
// Synced work orders carry the CRM external id in WorkerOrderNumber; the SH
// display number the board renders is stamped on InternalWONumber.
var workOrder = new WorkOrder
{
InternalWONumber = "10000000001",
WorkerOrderNumber = "CRM-EXT-77",
SiteCode = "SITE-EAST",
Service = "HVAC",
WorkerOrderTitle = "Repair"
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Should().ContainSingle().Subject;
item.WorkOrderNumber.Should().Be("10000000001");
item.WorkOrderNumber.Should().NotBe("CRM-EXT-77");
}
[Fact]
public async Task List_RequesterLabelFallsBackToCreatingUserName()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(Request(
dispatch, "Pending", new DateTime(2026, 3, 2),
requestedBy: null, createdBy: "user-7"));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Single().RequestedByName.Should().Be("Ada Smith");
}
[Fact]
public async Task List_MapsEvidenceAttachmentSummary()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
var evidence = new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
OriginalFileName = "quote.pdf",
StoredFileName = "evidence.bin",
ContentType = "application/pdf",
SizeBytes = 512,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = "UpliftEvidence",
Version = 1
};
context.VendorCompletionDocuments.Add(evidence);
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 200m,
EvidenceDocumentId = evidence.Id
});
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Single();
item.EvidenceDocumentId.Should().Be(evidence.Id);
item.EvidenceFileName.Should().Be("quote.pdf");
item.EvidenceContentType.Should().Be("application/pdf");
item.EvidenceSizeBytes.Should().Be(512);
}
// --- Queue contract fields (SH-208/SH-213) ---
[Fact]
public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle()
{
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", "Plumbing");
workOrderA.LifecycleStatus = LifecycleStatus.Completed;
workOrderB.LifecycleStatus = LifecycleStatus.Canceled;
await context.SaveChangesAsync();
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical");
var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
context.DispatchUpliftRequests.AddRange(
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
Request(dispatchB, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4)),
Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None);
result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue();
result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue();
result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse();
}
[Fact]
public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Approved",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2),
DecidedByUserId = "user-9",
RequiredTier = 1,
RequestedNTE = 300m,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
}
[Fact]
public async Task List_AttachmentCount_CountsActiveDocumentsOnTheDispatch()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
context.VendorCompletionDocuments.AddRange(
new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
OriginalFileName = "quote.pdf",
StoredFileName = "a.bin",
ContentType = "application/pdf",
SizeBytes = 512,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = "UpliftEvidence",
Version = 1
},
new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
OriginalFileName = "photo.jpg",
StoredFileName = "b.bin",
ContentType = "image/jpeg",
SizeBytes = 2048,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = "Completion",
Version = 1
},
new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
OriginalFileName = "old.pdf",
StoredFileName = "c.bin",
ContentType = "application/pdf",
SizeBytes = 128,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = "Completion",
Version = 1,
IsDeleted = true
});
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Single().AttachmentCount.Should().Be(2);
}
[Fact]
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
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.
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.
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));
await context.SaveChangesAsync();
var service = NewService(context);
// Page 1 with a single row still reports the total across ALL pending rows.
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None);
result.Items.Should().HaveCount(1);
result.PendingExposureTotal.Should().Be(740m);
}
// --- Approved-on-WO exposure totals ---
[Fact]
public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A");
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B");
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
context.DispatchUpliftRequests.AddRange(
// Vendor-portal row (createdby null): stores the requested new NTE total,
// so the granted amount is 400 - 100 = 300.
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m),
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "NoApprovalRequired",
CreatedDate = new DateTime(2026, 3, 1),
RequiredTier = 0,
RequestedNTE = 150m
},
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m),
Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m),
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Withdrawn",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 999m
},
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Revoked",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 999m
},
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Expired",
CreatedDate = new DateTime(2026, 3, 1),
RequiredTier = 1,
RequestedNTE = 999m
},
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A");
itemA.WorkOrderAutoApprovedTotal.Should().Be(150m);
itemA.WorkOrderAdminApprovedTotal.Should().Be(300m);
itemA.WorkOrderApprovedExposureTotal.Should().Be(450m);
var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B");
itemB.WorkOrderAutoApprovedTotal.Should().Be(0m);
itemB.WorkOrderAdminApprovedTotal.Should().Be(75m);
itemB.WorkOrderApprovedExposureTotal.Should().Be(75m);
}
[Fact]
public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN");
context.DispatchUpliftRequests.AddRange(
Request(
dispatch,
"Approved",
new DateTime(2026, 3, 1),
decided: new DateTime(2026, 3, 2),
requested: 400m,
currentNte: 100m),
Request(
dispatch,
"Approved",
new DateTime(2026, 3, 3),
decided: new DateTime(2026, 3, 4),
requested: 250m,
createdBy: "internal-user",
currentNte: 100m));
await context.SaveChangesAsync();
var exposure = await new UpliftDataService(context)
.GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None);
exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m);
}
[Fact]
public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var linkedDispatch = new Dispatch
{
VendorId = vendorA.Id,
DispatchNumber = "DIS-LINKED",
Status = "Completed"
};
context.Dispatches.Add(linkedDispatch);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = linkedDispatch.Id,
WorkOrderId = workOrderA.Id
});
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(Request(
linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m));
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
new[] { workOrderA.Id }, CancellationToken.None);
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject;
total.AdminApprovedTotal.Should().Be(60m);
total.AutoApprovedTotal.Should().Be(0m);
}
[Fact]
public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
context.DispatchUpliftRequests.AddRange(
// Vendor-portal rows (createdby null) store the requested new NTE total:
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
// Work-order-path rows (createdby set) store the granted increment: 200.
Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
new[] { workOrder.Id }, CancellationToken.None);
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
// double-count whole NTE totals and report 3500.
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
total.AdminApprovedTotal.Should().Be(1000m);
total.AutoApprovedTotal.Should().Be(0m);
}
[Fact]
public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
context.DispatchUpliftRequests.AddRange(
// Vendor-portal rows (createdby null) store the requested new NTE total:
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
// Work-order-path rows (createdby set) store the granted increment: 200.
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
new[] { workOrder.Id }, CancellationToken.None);
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
// double-count whole NTE totals and report 3500.
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
total.AutoApprovedTotal.Should().Be(1000m);
total.AdminApprovedTotal.Should().Be(0m);
}
// --- Read authorization (tier roles) ---
[Fact]
public async Task List_CanDecide_ReflectsTierRolesForTheCaller()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2));
await context.SaveChangesAsync();
var service = NewService(context);
var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue();
tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse();
tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue();
tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse();
}
}

View file

@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests;
public sealed class UpliftServiceRefusedTests
{
[Fact]
public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow()
{
var request = new DispatchUpliftRequest
{
Id = 7,
DispatchId = 42,
Status = "Approved",
RequiredTier = 1,
RequestedNTE = 900m
};
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(request);
upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(77);
var workOrderFlow = new Mock<IWorkOrderUpliftService>();
workOrderFlow.Setup(flow => flow.RevokeAsync(
77,
7,
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" });
var service = new UpliftService(
upliftData.Object,
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDocumentStoragePort>(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
workOrderFlow.Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
},
"test"));
var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None);
result.Id.Should().Be(7);
result.Status.Should().Be("Revoked");
workOrderFlow.Verify(flow => flow.RevokeAsync(
77,
7,
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
{

View file

@ -30,11 +30,15 @@ namespace Api.SeaHavenIndustries.Controllers
open = stats.Open,
notDispatched = stats.NotDispatched,
completed = stats.Completed,
scheduledTomorrow = stats.ScheduledTomorrow,
pendingUplifts = stats.PendingUplifts,
avetaPending = stats.AvetaPending,
breakdown = stats.Breakdown,
dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount,
completionRate = stats.CompletionRate,
averageResolutionDays = stats.AverageResolutionDays
averageResolutionDays = stats.AverageResolutionDays,
statusDistribution = stats.StatusDistribution
});
}

View file

@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default)
{
try
{
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
var data = pagedResult.Items.Select(l => new
{
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = l.Email,
Status = l.Status,
AccountId = l.AccountId,
ClientName = l.AccountName,
AccountName = l.AccountName,
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
});

View file

@ -0,0 +1,110 @@
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/services")]
public class ServicesRegistryController : Controller
{
private readonly IServicesRegistryService _servicesRegistryService;
public ServicesRegistryController(IServicesRegistryService servicesRegistryService)
{
_servicesRegistryService = servicesRegistryService;
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
try
{
var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken);
return Ok(services);
}
catch (ServicesRegistryValidationException ex)
{
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken);
if (service == null)
return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." });
return Ok(service);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] ServiceInput input, CancellationToken cancellationToken)
{
try
{
var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken);
return StatusCode(StatusCodes.Status201Created, created);
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
{
return MapForbidden(ex);
}
catch (ServicesRegistryValidationException ex)
{
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken)
{
try
{
var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken);
return Ok(updated);
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
{
return MapForbidden(ex);
}
catch (ServicesRegistryValidationException ex)
{
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id}/deactivate")]
public async Task<IActionResult> Deactivate(int id, CancellationToken cancellationToken)
{
try
{
await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken);
return Ok(new { message = "Service deactivated" });
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
{
return MapForbidden(ex);
}
}
private IActionResult MapForbidden(ServicesRegistryValidationException ex)
{
return StatusCode(StatusCodes.Status403Forbidden,
new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}

View file

@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPost("{id:int}/revoke")]
public async Task<IActionResult> Revoke(
int id,
[FromBody] DecisionRequest? body,
CancellationToken cancellationToken = default)
{
try
{
var result = await _upliftService.RevokeAsync(
User,
id,
body?.Note ?? string.Empty,
cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to revoke this uplift") });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") });
}
}
[HttpGet("can-approve")]
public IActionResult CanApprove([FromQuery] int tier)
{

View file

@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries
.HasIndex(w => w.InternalWONumber)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasOne(w => w.ServiceDefinition)
.WithMany()
.HasForeignKey(w => w.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
@ -213,6 +219,53 @@ namespace Data.SeaHavenIndustries
.HasIndex(t => t.NormalizedName)
.IsUnique();
// Bounded lengths keep the unique NormalizedName key SQL
// Server-indexable (nvarchar(max) is not).
builder.Entity<Service>()
.Property(s => s.Name)
.HasMaxLength(200);
builder.Entity<Service>()
.Property(s => s.NormalizedName)
.HasMaxLength(200);
builder.Entity<Service>()
.HasIndex(s => s.NormalizedName)
.IsUnique();
builder.Entity<Service>()
.Property(s => s.Trade)
.HasMaxLength(64);
builder.Entity<Service>()
.HasIndex(s => s.Trade);
builder.Entity<Service>()
.Property(s => s.IconKey)
.HasMaxLength(64);
builder.Entity<Service>()
.HasIndex(s => s.IsActive);
builder.Entity<Service>()
.HasOne(s => s.CompletionDocTemplate)
.WithMany()
.HasForeignKey(s => s.CompletionDocTemplateId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Service>()
.HasIndex(s => s.CompletionDocTemplateId);
builder.Entity<ServiceWorkOrderType>()
.HasIndex(t => new { t.ServiceId, t.WorkOrderType })
.IsUnique();
builder.Entity<ServiceWorkOrderType>()
.HasOne(t => t.Service)
.WithMany(s => s.SupportedWorkOrderTypes)
.HasForeignKey(t => t.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
// SH-278 Area catalogue: bounded names so the unique key is indexable.
builder.Entity<Area>()
.Property(a => a.Name)
@ -313,6 +366,8 @@ namespace Data.SeaHavenIndustries
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
public DbSet<Service> Services { get; set; }
public DbSet<ServiceWorkOrderType> ServiceWorkOrderTypes { get; set; }
// New DbSets for added entities
public DbSet<Accounts> Accounts { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH303_ServicesRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Services",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Trade = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
IconKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
RequiresCompletionDocument = table.Column<bool>(type: "bit", nullable: false),
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Services", x => x.Id);
table.ForeignKey(
name: "FK_Services_CompletionDocTemplates_CompletionDocTemplateId",
column: x => x.CompletionDocTemplateId,
principalTable: "CompletionDocTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ServiceWorkOrderTypes",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ServiceId = table.Column<int>(type: "int", nullable: false),
WorkOrderType = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ServiceWorkOrderTypes", x => x.Id);
table.ForeignKey(
name: "FK_ServiceWorkOrderTypes_Services_ServiceId",
column: x => x.ServiceId,
principalTable: "Services",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Services_CompletionDocTemplateId",
table: "Services",
column: "CompletionDocTemplateId");
migrationBuilder.CreateIndex(
name: "IX_Services_IsActive",
table: "Services",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_Services_NormalizedName",
table: "Services",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Services_Trade",
table: "Services",
column: "Trade");
migrationBuilder.CreateIndex(
name: "IX_ServiceWorkOrderTypes_ServiceId_WorkOrderType",
table: "ServiceWorkOrderTypes",
columns: new[] { "ServiceId", "WorkOrderType" },
unique: true);
// Additive seed: one active service per active Problem dropdown
// value already stored in the database (never the prototype's demo
// leaves). Idempotent via NormalizedName NOT EXISTS; dropdown rows are
// only read, never altered. Repeated source values collapse onto a
// deterministic MIN(Value) representative per normalized key.
migrationBuilder.Sql(
"""
INSERT INTO [Services] ([Name], [NormalizedName], [Trade], [IconKey], [RequiresCompletionDocument], [CompletionDocTemplateId], [IsActive], [IsDeleted], [createdby])
SELECT seeded.[Name], seeded.[NormalizedName], seeded.[Trade],
CASE seeded.[Trade]
WHEN N'Plumbing & Water Systems' THEN N'plumbing-water-systems'
WHEN N'Electrical' THEN N'electrical'
WHEN N'HVAC' THEN N'hvac'
WHEN N'Fire & Life Safety' THEN N'fire-life-safety'
ELSE N'general-building-handyman'
END,
0, NULL, 1, 0, N'sh303-migration'
FROM (
SELECT agg.[Name], agg.[NormalizedName],
CASE
WHEN agg.[Name] IN (N'Leak', N'Clogged') THEN N'Plumbing & Water Systems'
WHEN agg.[Name] = N'No Power' THEN N'Electrical'
WHEN agg.[Name] IN (N'Not Cooling', N'Not Heating') THEN N'HVAC'
WHEN agg.[Name] = N'Alarm Fault' THEN N'Fire & Life Safety'
ELSE N'General Building & Handyman'
END AS [Trade]
FROM (
SELECT MIN(src.[Value]) AS [Name],
LOWER(LTRIM(RTRIM(src.[Value]))) AS [NormalizedName]
FROM [DropdownOptions] src
WHERE src.[Category] = N'Problem'
AND src.[IsActive] = 1
GROUP BY LOWER(LTRIM(RTRIM(src.[Value])))
) agg
) seeded
WHERE NOT EXISTS (
SELECT 1 FROM [Services] s WHERE s.[NormalizedName] = seeded.[NormalizedName]
);
INSERT INTO [ServiceWorkOrderTypes] ([ServiceId], [WorkOrderType], [IsDeleted])
SELECT s.[Id], types.[WorkOrderType], 0
FROM [Services] s
CROSS JOIN (VALUES (2), (6), (3)) AS types([WorkOrderType])
WHERE s.[createdby] = N'sh303-migration'
AND NOT EXISTS (
SELECT 1 FROM [ServiceWorkOrderTypes] x
WHERE x.[ServiceId] = s.[Id]
AND x.[WorkOrderType] = types.[WorkOrderType]
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServiceWorkOrderTypes");
migrationBuilder.DropTable(
name: "Services");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH187_WorkOrderServiceLink : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ServiceId",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ServiceNameSnapshot",
table: "workOrders",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.Sql(
"""
UPDATE w
SET
w.[ServiceNameSnapshot] = LEFT(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')), 200),
w.[ServiceId] = s.[Id]
FROM [workOrders] w
LEFT JOIN [Services] s
ON (s.[IsDeleted] <> 1 OR s.[IsDeleted] IS NULL)
AND s.[NormalizedName] = LOWER(LTRIM(RTRIM(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')))))
WHERE w.[ServiceNameSnapshot] IS NULL;
""");
migrationBuilder.CreateIndex(
name: "IX_workOrders_ServiceId",
table: "workOrders",
column: "ServiceId");
migrationBuilder.AddForeignKey(
name: "FK_workOrders_Services_ServiceId",
table: "workOrders",
column: "ServiceId",
principalTable: "Services",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_workOrders_Services_ServiceId",
table: "workOrders");
migrationBuilder.DropIndex(
name: "IX_workOrders_ServiceId",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ServiceId",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ServiceNameSnapshot",
table: "workOrders");
}
}
}

View file

@ -1805,6 +1805,121 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Regions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("CompletionDocTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("IconKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("RequiresCompletionDocument")
.HasColumnType("bit");
b.Property<string>("Trade")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompletionDocTemplateId");
b.HasIndex("IsActive");
b.HasIndex("NormalizedName")
.IsUnique();
b.HasIndex("Trade");
b.ToTable("Services");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<int>("ServiceId")
.HasColumnType("int");
b.Property<int>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ServiceId", "WorkOrderType")
.IsUnique();
b.ToTable("ServiceWorkOrderTypes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.Property<int>("Id")
@ -2681,6 +2796,13 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<int?>("ServiceId")
.HasColumnType("int");
b.Property<string>("ServiceNameSnapshot")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ServiceNotes")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
@ -2765,6 +2887,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ScheduledDate");
b.HasIndex("ServiceId");
b.HasIndex("SiteCode")
.HasFilter("[istemplate] = 0");
@ -3610,6 +3734,27 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
{
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
.WithMany()
.HasForeignKey("CompletionDocTemplateId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CompletionDocTemplate");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
{
b.HasOne("Data.SeaHavenIndustries.Service", "Service")
.WithMany("SupportedWorkOrderTypes")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
@ -3784,6 +3929,11 @@ namespace Data.SeaHavenIndustries.Migrations
.HasForeignKey("PrimaryDispatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
b.Navigation("AssignToUser");
@ -3791,6 +3941,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Locations");
b.Navigation("PrimaryDispatch");
b.Navigation("ServiceDefinition");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -4012,6 +4164,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Employees");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
{
b.Navigation("SupportedWorkOrderTypes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
{
b.Navigation("Items");

View file

@ -0,0 +1,29 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
// Global (tenant-scope free) Services registry. Services are never
// hard deleted; deactivation is a soft IsActive state change so historical
// work-order references remain resolvable.
public class Service : FullAuditEntity
{
public string Name { get; set; } = "";
public string NormalizedName { get; set; } = "";
public string Trade { get; set; } = "";
public string IconKey { get; set; } = "";
public bool RequiresCompletionDocument { get; set; }
public int? CompletionDocTemplateId { get; set; }
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
public bool IsActive { get; set; } = true;
public virtual ICollection<ServiceWorkOrderType> SupportedWorkOrderTypes { get; set; } = new List<ServiceWorkOrderType>();
}
// Supported work-order types (PM, Reactive, Emergency) stored as an
// explicitly queryable relation so work-order features can filter by type.
public class ServiceWorkOrderType : FullAuditEntity
{
public int ServiceId { get; set; }
public virtual Service? Service { get; set; }
public WorkOrderType WorkOrderType { get; set; }
}
}

View file

@ -81,6 +81,15 @@ namespace Data.SeaHavenIndustries
public string? PONumber { get; set; }
public int? WorkOrderId { get; set; }
public string? VendorCompanyName { get; set; }
// Approval queue read contract: flattened work-order context.
public string? WorkOrderNumber { get; set; }
public string? WorkOrderSiteCode { get; set; }
public string? WorkOrderService { get; set; }
public string? RequestedByVendorName { get; set; }
public string? RequestedByFirstName { get; set; }
public string? RequestedByLastName { get; set; }
public string? DecidedByFirstName { get; set; }
public string? DecidedByLastName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
public string? VendorReason { get; set; }
@ -97,6 +106,8 @@ namespace Data.SeaHavenIndustries
public DateTime? ExpiresAt { get; set; }
public string? NotificationStatus { get; set; }
public string? NotificationError { get; set; }
public int AttachmentCount { get; set; }
public bool WorkOrderClosed { get; set; }
}
public class UpliftForWorkOrderData : UpliftForDispatchData
@ -130,6 +141,16 @@ namespace Data.SeaHavenIndustries
public bool EvidenceScanPassed { get; set; }
}
// Approval queue read contract: per-work-order exposure totals. Approved exposure is
// the sum of uplift amounts that were auto-approved or admin-approved on the work
// order; pending, rejected, cancelled/withdrawn, and revoked requests are excluded.
public class WorkOrderUpliftExposureData
{
public int WorkOrderId { get; set; }
public decimal AutoApprovedTotal { get; set; }
public decimal AdminApprovedTotal { get; set; }
}
// SH-101: internal evidence-download projection. Server-side join enforces that the
// returned document is the one linked to this specific uplift request and dispatch.
public class UpliftEvidenceDownloadData

View file

@ -43,6 +43,11 @@ namespace Data.SeaHavenIndustries
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)]
public string? Service { get; set; }
public int? ServiceId { get; set; }
[MaxLength(200)]
public string? ServiceNameSnapshot { get; set; }
[ForeignKey(nameof(ServiceId))]
public virtual Service? ServiceDefinition { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; }

View file

@ -34,6 +34,8 @@ namespace SeaHaven.DataServices.Helpers
w.OriginalDate,
w.OriginalWeek,
w.WorkOrderType,
w.ServiceId,
w.ServiceNameSnapshot,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
WoPocName = w.PocName,
@ -136,6 +138,8 @@ namespace SeaHaven.DataServices.Helpers
w.CarriedOver,
w.IsAddOn,
w.WorkOrderType,
w.ServiceId,
w.ServiceNameSnapshot,
frozenSite?.Code ?? w.SiteCode,
frozenSite?.Label ?? w.LocationName,
pocName,

View file

@ -121,6 +121,40 @@ namespace SeaHaven.DataServices.Helpers
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
}
public static IQueryable<WorkOrder> ApplyRegionFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? regions)
{
if (regions == null || regions.Count == 0)
return query;
var normalized = regions
.Where(region => !string.IsNullOrWhiteSpace(region))
.Select(region => region.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalized.Count == 0)
return query;
var knownRegions = normalized
.Where(region => WorkOrderBoardRegions.Names.Contains(region) && region != "Unmapped/Other")
.SelectMany(WorkOrderBoardRegions.StorageValuesFor)
.Distinct(StringComparer.Ordinal)
.ToList();
var includeUnmapped = normalized.Contains("Unmapped/Other", StringComparer.Ordinal);
var hasKnownRegion = knownRegions.Count > 0;
if (!hasKnownRegion && !includeUnmapped)
return query.Where(_ => false);
return query.Where(w =>
(hasKnownRegion && w.Locations != null && w.Locations.State != null && knownRegions.Contains(w.Locations.State))
|| (includeUnmapped && (w.Locations == null
|| w.Locations.State == null
|| w.Locations.State == ""
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
}
public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)

View file

@ -0,0 +1,82 @@
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardRegions
{
public static readonly IReadOnlyList<string> Names =
["East", "Central", "West", "California", "Unmapped/Other"];
private static readonly IReadOnlyDictionary<string, string[]> RegionStateCodes =
new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["East"] = ["CT", "DE", "GA", "ME", "MD", "MA", "NH", "NJ", "NY", "NC", "PA", "RI", "SC", "VT", "VA", "WV"],
["Central"] = ["AL", "AR", "FL", "IL", "IN", "IA", "KS", "KY", "LA", "MI", "MN", "MS", "MO", "NE", "ND", "OH", "OK", "SD", "TN", "WI"],
["West"] = ["AK", "AZ", "CO", "HI", "ID", "MT", "NV", "NM", "OR", "TX", "UT", "WA", "WY"],
["California"] = ["CA"]
};
private static readonly IReadOnlyDictionary<string, string> StateNames =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["AL"] = "ALABAMA",
["AK"] = "ALASKA",
["AZ"] = "ARIZONA",
["AR"] = "ARKANSAS",
["CA"] = "CALIFORNIA",
["CO"] = "COLORADO",
["CT"] = "CONNECTICUT",
["DE"] = "DELAWARE",
["FL"] = "FLORIDA",
["GA"] = "GEORGIA",
["HI"] = "HAWAII",
["ID"] = "IDAHO",
["IL"] = "ILLINOIS",
["IN"] = "INDIANA",
["IA"] = "IOWA",
["KS"] = "KANSAS",
["KY"] = "KENTUCKY",
["LA"] = "LOUISIANA",
["ME"] = "MAINE",
["MD"] = "MARYLAND",
["MA"] = "MASSACHUSETTS",
["MI"] = "MICHIGAN",
["MN"] = "MINNESOTA",
["MS"] = "MISSISSIPPI",
["MO"] = "MISSOURI",
["MT"] = "MONTANA",
["NE"] = "NEBRASKA",
["NV"] = "NEVADA",
["NH"] = "NEW HAMPSHIRE",
["NJ"] = "NEW JERSEY",
["NM"] = "NEW MEXICO",
["NY"] = "NEW YORK",
["NC"] = "NORTH CAROLINA",
["ND"] = "NORTH DAKOTA",
["OH"] = "OHIO",
["OK"] = "OKLAHOMA",
["OR"] = "OREGON",
["PA"] = "PENNSYLVANIA",
["RI"] = "RHODE ISLAND",
["SC"] = "SOUTH CAROLINA",
["SD"] = "SOUTH DAKOTA",
["TN"] = "TENNESSEE",
["TX"] = "TEXAS",
["UT"] = "UTAH",
["VT"] = "VERMONT",
["VA"] = "VIRGINIA",
["WA"] = "WASHINGTON",
["WV"] = "WEST VIRGINIA",
["WI"] = "WISCONSIN",
["WY"] = "WYOMING"
};
public static IReadOnlyCollection<string> StorageValuesFor(string region)
=> RegionStateCodes.TryGetValue(region, out var codes)
? codes.SelectMany(code => new[] { code, StateNames[code] }).ToArray()
: Array.Empty<string>();
public static IReadOnlySet<string> KnownStorageValues { get; } =
RegionStateCodes.Values
.SelectMany(codes => codes.SelectMany(code => new[] { code, StateNames[code] }))
.ToHashSet(StringComparer.Ordinal);
}
}

View file

@ -88,6 +88,70 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(cancellationToken);
}
public async Task<DashboardKpiCounts> GetKpiCountsAsync(
int? accountId,
string? dispatcherId,
DateOnly today,
CancellationToken cancellationToken)
{
var tomorrow = today.AddDays(1);
var dayAfterTomorrow = tomorrow.AddDays(1);
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
if (dispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
var scheduledTomorrow = await workOrders
.Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue)
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue))
.CountAsync(cancellationToken);
var avetaPending = await workOrders
.Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue)
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)
&& w.AvetaRequired
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& (w.LifecycleStatus != null
|| ((w.LegacyStatus ?? w.Status) != "Done"
&& (w.LegacyStatus ?? w.Status) != "Completed"
&& (w.LegacyStatus ?? w.Status) != "Complete"
&& (w.LegacyStatus ?? w.Status) != "Closed"
&& (w.LegacyStatus ?? w.Status) != "Canceled"
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
&& !w.workOrderAttachments!.Any(attachment =>
(attachment.IsDeleted == null || attachment.IsDeleted == false)
&& attachment.Category == WorkOrderMediaCategory.Aveta))
.CountAsync(cancellationToken);
var pendingUplifts = _context.DispatchUpliftRequests
.AsNoTracking()
.Where(request => request.Status == "Pending"
&& (request.IsDeleted == null || request.IsDeleted == false)
&& request.Dispatch != null
&& (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false));
if (accountId is int account)
{
pendingUplifts = pendingUplifts.Where(request =>
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account)
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
link.WorkOrder != null && link.WorkOrder.AccountId == account));
}
if (dispatcherId != null)
{
pendingUplifts = pendingUplifts.Where(request =>
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId)
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId));
}
return new DashboardKpiCounts(
scheduledTomorrow,
await pendingUplifts.CountAsync(cancellationToken),
avetaPending);
}
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
int? accountId,
string? dispatcherId,
@ -102,7 +166,6 @@ namespace SeaHaven.DataServices.Implementation
on userRole.RoleId equals role.Id
where user.IsDeleted != true
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
&& (dispatcherId == null || user.Id == dispatcherId)
select new
{
user.Id,
@ -114,6 +177,9 @@ namespace SeaHaven.DataServices.Implementation
if (accountId is int scopedAccountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
if (dispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
if (dateFrom is DateOnly from && dateTo is DateOnly to)
{
var fromDate = from.ToDateTime(TimeOnly.MinValue);

View file

@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation
int pageSize,
string? search,
IReadOnlyCollection<string>? states,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? sortBy = null,
string? sortDirection = null)
{
var query = _context.Locations.AsNoTracking();
IQueryable<Locations> query = _context.Locations
.AsNoTracking()
.Include(l => l.Account);
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(l =>
l.Name!.Contains(search) ||
l.City!.Contains(search));
l.Address1!.Contains(search) ||
l.City!.Contains(search) ||
l.Account != null && l.Account.Name!.Contains(search));
}
if (states is { Count: > 0 })
@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
}
query = ApplyListSort(query, sortBy, sortDirection);
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(l => l.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
private static IQueryable<Locations> ApplyListSort(IQueryable<Locations> query, string? sortBy, string? sortDirection)
{
var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase);
IOrderedQueryable<Locations> ordered = (sortBy?.Trim().ToLowerInvariant()) switch
{
"code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name),
"client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name),
"address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1),
"city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City),
"state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State),
"poc" => descending
? query.OrderByDescending(FirstActiveContactName)
: query.OrderBy(FirstActiveContactName),
_ => query.OrderBy(l => l.Name)
};
return ordered.ThenBy(l => l.Id);
}
private static readonly System.Linq.Expressions.Expression<Func<Locations, string?>> FirstActiveContactName =
l => l.Contacts
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => c.FirstName)
.FirstOrDefault();
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations

View file

@ -0,0 +1,155 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Implementation
{
public class ServicesRegistryDataService : IServicesRegistryDataService
{
private readonly ApplicationDbContext _context;
public ServicesRegistryDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
var query = _context.Services
.AsNoTracking()
.Where(s => s.IsDeleted != true);
if (isActive.HasValue)
query = query.Where(s => s.IsActive == isActive.Value);
if (workOrderType.HasValue)
query = query.Where(s => s.SupportedWorkOrderTypes.Any(t => t.WorkOrderType == workOrderType.Value));
var rows = await query
.OrderBy(s => s.Name)
.Select(s => new ServiceRegistryRow
{
Id = s.Id,
Name = s.Name,
Trade = s.Trade,
IconKey = s.IconKey,
RequiresCompletionDocument = s.RequiresCompletionDocument,
IsActive = s.IsActive,
CompletionDocTemplateId = s.CompletionDocTemplateId,
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
})
.ToListAsync(cancellationToken);
return rows;
}
public async Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.Services.AsNoTracking()
.Where(s => s.Id == id && s.IsDeleted != true)
.Select(s => new ServiceRegistryRow
{
Id = s.Id,
Name = s.Name,
Trade = s.Trade,
IconKey = s.IconKey,
RequiresCompletionDocument = s.RequiresCompletionDocument,
IsActive = s.IsActive,
CompletionDocTemplateId = s.CompletionDocTemplateId,
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
})
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken)
{
return await _context.Services
.Include(s => s.SupportedWorkOrderTypes)
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
}
public async Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken)
{
return await _context.Services
.AnyAsync(s => s.IsDeleted != true
&& s.NormalizedName == normalizedName
&& (excludeServiceId == null || s.Id != excludeServiceId),
cancellationToken);
}
public async Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken)
{
return await _context.CompletionDocTemplates
.Where(t => t.Id == templateId && t.IsActive && t.IsDeleted != true)
.Select(t => new LinkedTemplateSummary { Id = t.Id, Name = t.Name })
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken)
{
await _context.Services.AddAsync(service, cancellationToken);
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (IsUniqueNameViolation(ex))
{
throw new ServicesRegistryDuplicateNameException(ex);
}
return service;
}
public async Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken)
{
var desired = supportedWorkOrderTypes.Distinct().ToList();
var current = service.SupportedWorkOrderTypes ?? new List<ServiceWorkOrderType>();
var stale = current.Where(t => !desired.Contains(t.WorkOrderType)).ToList();
_context.ServiceWorkOrderTypes.RemoveRange(stale);
var existingTypes = current.Select(t => t.WorkOrderType).ToHashSet();
foreach (var type in desired.Where(t => !existingTypes.Contains(t)))
{
_context.ServiceWorkOrderTypes.Add(new ServiceWorkOrderType
{
ServiceId = service.Id,
WorkOrderType = type
});
}
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (IsUniqueNameViolation(ex))
{
throw new ServicesRegistryDuplicateNameException(ex);
}
}
public async Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken)
{
var service = await _context.Services
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
if (service == null)
return false;
service.IsActive = false;
await _context.SaveChangesAsync(cancellationToken);
return true;
}
private static bool IsUniqueNameViolation(DbUpdateException exception)
{
var message = exception.InnerException?.Message ?? exception.Message;
return message.Contains("IX_Services_NormalizedName", StringComparison.OrdinalIgnoreCase)
|| message.Contains("NormalizedName", StringComparison.OrdinalIgnoreCase)
&& (message.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
|| message.Contains("unique", StringComparison.OrdinalIgnoreCase));
}
}
}

View file

@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
@ -24,8 +25,15 @@ namespace SeaHaven.DataServices.Implementation
from v in vendors.DefaultIfEmpty()
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
from ev in evidences.DefaultIfEmpty()
join wo in _context.workOrders on d.WorkOrderId equals wo.Id into workOrders
from wo in workOrders.DefaultIfEmpty()
join reqUser in _context.Users on u.createdby equals reqUser.Id into requestUsers
from reqUser in requestUsers.DefaultIfEmpty()
join decUser in _context.Users on u.DecidedByUserId equals decUser.Id into decisionUsers
from decUser in decisionUsers.DefaultIfEmpty()
where (u.IsDeleted == null || u.IsDeleted == false)
select new { u, d, v, ev };
&& (d.IsDeleted == null || d.IsDeleted == false)
select new { u, d, v, ev, wo, reqUser, decUser };
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.u.Status == status);
@ -34,8 +42,18 @@ namespace SeaHaven.DataServices.Implementation
var total = await query.CountAsync(cancellationToken);
// Approval queue read contract: the actionable queue (Pending) surfaces the
// oldest request first; the decision log (Approved) surfaces the most
// recently decided first. Every other read keeps the historical
// newest-request-first order. Id is the deterministic tiebreaker.
if (string.Equals(status, "Pending", StringComparison.Ordinal))
query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id);
else if (string.Equals(status, "Approved", StringComparison.Ordinal))
query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id);
else
query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);
var items = await query
.OrderByDescending(x => x.u.CreatedDate)
.Skip((Math.Max(page, 1) - 1) * pageSize)
.Take(pageSize)
.Select(x => new UpliftListItemData
@ -46,6 +64,17 @@ namespace SeaHaven.DataServices.Implementation
PONumber = x.d.PONumber,
WorkOrderId = x.d.WorkOrderId,
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
// The queue renders the internal display number (InternalWONumber, what the
// board shows); WorkerOrderNumber holds the CRM external id on synced
// work orders and must not leak into the queue display.
WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null,
WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null,
WorkOrderService = x.wo != null ? x.wo.Service : null,
RequestedByVendorName = x.u.RequestedByVendorName,
RequestedByFirstName = x.reqUser != null ? x.reqUser.FirstName : null,
RequestedByLastName = x.reqUser != null ? x.reqUser.LastName : null,
DecidedByFirstName = x.decUser != null ? x.decUser.FirstName : null,
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
CurrentNTE = x.u.CurrentNTE,
RequestedNTE = x.u.RequestedNTE,
VendorReason = x.u.VendorReason,
@ -60,13 +89,29 @@ namespace SeaHaven.DataServices.Implementation
EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null,
ExpiresAt = x.u.ExpiresAt,
NotificationStatus = x.u.NotificationStatus,
NotificationError = x.u.NotificationError
NotificationError = x.u.NotificationError,
AttachmentCount = _context.VendorCompletionDocuments.Count(document =>
document.DispatchId == x.d.Id
&& (document.IsDeleted == null || document.IsDeleted == false)),
WorkOrderClosed = x.wo != null
&& (x.wo.LifecycleStatus == LifecycleStatus.Completed
|| x.wo.LifecycleStatus == LifecycleStatus.Canceled)
})
.ToListAsync(cancellationToken);
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
@ -200,6 +245,27 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<int?> GetWorkOrderIdForUpliftAsync(
int upliftRequestId,
CancellationToken cancellationToken)
{
var link = await _context.DispatchUpliftRequests
.Where(u => u.Id == upliftRequestId
&& (u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
.Select(u => new
{
PrimaryWorkOrderId = u.Dispatch!.WorkOrderId,
LinkedWorkOrderId = u.Dispatch.DispatchWorkOrders!
.Select(dispatchWorkOrder => (int?)dispatchWorkOrder.WorkOrderId)
.FirstOrDefault()
})
.FirstOrDefaultAsync(cancellationToken);
return link?.PrimaryWorkOrderId ?? link?.LinkedWorkOrderId;
}
public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
@ -252,6 +318,89 @@ namespace SeaHaven.DataServices.Implementation
.SumAsync(u => u.RequestedNTE, cancellationToken);
}
// Approval queue read contract: set-based exposure aggregation. Dispatches map to
// work orders through the server-derived linkage (primary work order plus linked
// work orders), matching the ForWorkOrder scope; approved amounts are summed per
// dispatch in SQL and folded into per-work-order totals in memory (three set-based
// round trips, no query-per-work-order).
public async Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken)
{
var workOrderIdsScope = workOrderIds.Distinct().ToList();
if (workOrderIdsScope.Count == 0)
return Array.Empty<WorkOrderUpliftExposureData>();
var primaryPairs = await _context.Dispatches
.Where(d => (d.IsDeleted == null || d.IsDeleted == false)
&& d.WorkOrderId != null
&& workOrderIdsScope.Contains(d.WorkOrderId.Value))
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value })
.ToListAsync(cancellationToken);
var linkedPairs = await _context.DispatchWorkOrders
.Where(l => workOrderIdsScope.Contains(l.WorkOrderId)
&& l.Dispatch != null
&& (l.Dispatch.IsDeleted == null || l.Dispatch.IsDeleted == false))
.Select(l => new { l.DispatchId, l.WorkOrderId })
.ToListAsync(cancellationToken);
var workOrdersByDispatch = primaryPairs
.Concat(linkedPairs)
.GroupBy(p => p.DispatchId)
.ToDictionary(g => g.Key, g => g.Select(p => p.WorkOrderId).ToHashSet());
if (workOrdersByDispatch.Count == 0)
return Array.Empty<WorkOrderUpliftExposureData>();
var dispatchIds = workOrdersByDispatch.Keys.ToList();
var sums = await _context.DispatchUpliftRequests
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& dispatchIds.Contains(u.DispatchId)
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
.GroupBy(u => u.DispatchId)
.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))
})
.ToListAsync(cancellationToken);
var totals = new Dictionary<int, WorkOrderUpliftExposureData>();
foreach (var sum in sums)
{
if (!workOrdersByDispatch.TryGetValue(sum.DispatchId, out var linkedWorkOrders))
continue;
foreach (var workOrderId in linkedWorkOrders)
{
if (!totals.TryGetValue(workOrderId, out var total))
{
total = new WorkOrderUpliftExposureData { WorkOrderId = workOrderId };
totals[workOrderId] = total;
}
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
}
}
return totals.Values.ToList();
}
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
int workOrderId,
CancellationToken cancellationToken)

View file

@ -24,6 +24,7 @@ namespace SeaHaven.DataServices.Implementation
else
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);

View file

@ -8,6 +8,12 @@ namespace SeaHaven.DataServices.Interfaces
int? accountId,
CancellationToken cancellationToken);
Task<DashboardKpiCounts> GetKpiCountsAsync(
int? accountId,
string? dispatcherId,
DateOnly today,
CancellationToken cancellationToken);
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,
@ -53,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
public int NotDispatched { get; set; }
public int Completed { get; set; }
}
public sealed record DashboardKpiCounts(
int ScheduledTomorrow,
int PendingUplifts,
int AvetaPending);
}

View file

@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces
Task<bool> ExistsAsync(int id);
Task<int> CountAsync();
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
/// <summary>
/// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order.
/// Sort options trail the token so existing call sites stay source-compatible.
/// </summary>
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null);
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);

View file

@ -0,0 +1,39 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
// Read-side projection for the global Services registry. The linked
// completion template is projected to id/name only — full template
// entities are never materialized.
public class ServiceRegistryRow
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Trade { get; set; } = "";
public string IconKey { get; set; } = "";
public bool RequiresCompletionDocument { get; set; }
public bool IsActive { get; set; }
public int? CompletionDocTemplateId { get; set; }
public string? CompletionDocTemplateName { get; set; }
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
}
public class LinkedTemplateSummary
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public interface IServicesRegistryDataService
{
Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken);
Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken);
Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken);
Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken);
Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken);
Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken);
Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken);
Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken);
}
}

View file

@ -6,11 +6,13 @@ 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);
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
// SH-101: server-side join of an uplift request with its linked evidence document.
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
@ -18,6 +20,12 @@ namespace SeaHaven.DataServices.Interfaces
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, 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,
// cancelled/withdrawn, and revoked requests are excluded). Work orders absent
// from the result have no approved exposure.
Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);

View file

@ -9,6 +9,7 @@ namespace SeaHaven.DataServices.Interfaces
DateOnly DateTo,
bool UnscheduledOnly,
IReadOnlyList<string>? Sites,
IReadOnlyList<string>? Regions,
IReadOnlyList<WorkOrderType>? Types,
bool Overdue,
IReadOnlyList<string>? Dispatchers,

View file

@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Interfaces
int CarriedOver,
bool IsAddOn,
WorkOrderType? WorkOrderType,
int? ServiceId,
string? ServiceNameSnapshot,
string? SiteCode,
string? LocationName,
string? PocName,

View file

@ -0,0 +1,14 @@
namespace SeaHaven.DataServices.Models
{
/// <summary>
/// Raised by the registry data service when the database unique name index
/// wins a concurrent create or update race.
/// </summary>
public sealed class ServicesRegistryDuplicateNameException : Exception
{
public ServicesRegistryDuplicateNameException(Exception innerException)
: base("A service name is already in use.", innerException)
{
}
}
}

View file

@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs
{
public DateOnly? DateFrom { get; set; }
public DateOnly? DateTo { get; set; }
public string? DispatcherId { get; set; }
}
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
@ -63,6 +64,8 @@ namespace SeaHaven.Services.DTOs
public string? Color { get; init; }
public decimal CompletionRate { get; init; }
public decimal? AverageResolutionDays { get; init; }
public int AssignedCount { get; init; }
public int CompletedCount { get; init; }
}
public sealed class DashboardRegionResponseDTO
@ -100,11 +103,21 @@ namespace SeaHaven.Services.DTOs
public int Open { get; set; }
public int NotDispatched { get; set; }
public int Completed { get; set; }
public int ScheduledTomorrow { get; set; }
public int PendingUplifts { get; set; }
public int AvetaPending { get; set; }
public DashboardBreakdownDTO Breakdown { get; set; } = new();
public int DueCount { get; set; }
public int CompletedDueCount { get; set; }
public decimal CompletionRate { get; set; }
public decimal? AverageResolutionDays { get; set; }
public IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution { get; set; } = [];
}
public sealed class DashboardStatusBucketDTO
{
public string Status { get; init; } = string.Empty;
public int Count { get; init; }
}
public sealed class DashboardBreakdownDTO

View file

@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs
public string? Email { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
public string? AccountName { get; set; }
public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; }

View file

@ -0,0 +1,39 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs
{
public class ServiceDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Trade { get; set; } = "";
public string IconKey { get; set; } = "";
public bool RequiresCompletionDocument { get; set; }
public LinkedTemplateSummaryDto? CompletionDocTemplate { get; set; }
public bool IsActive { get; set; }
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
}
public class LinkedTemplateSummaryDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public class ServiceInput
{
public string? Name { get; set; }
public string? Trade { get; set; }
public string? IconKey { get; set; }
public bool RequiresCompletionDocument { get; set; }
public int? CompletionDocTemplateId { get; set; }
public List<WorkOrderType>? SupportedWorkOrderTypes { get; set; }
public bool? IsActive { get; set; }
}
public class ServicesRegistryValidationErrorDto
{
public string Code { get; set; } = "";
public string Message { get; set; } = "";
}
}

View file

@ -8,6 +8,12 @@ namespace SeaHaven.Services.DTOs
public string? PONumber { get; set; }
public int? WorkOrderId { get; set; }
public string? VendorCompanyName { get; set; }
// Approval queue read contract: flattened work-order context and requester.
public string? WorkOrderNumber { get; set; }
public string? WorkOrderSite { get; set; }
public string? WorkOrderService { get; set; }
public string? RequestedByName { get; set; }
public string? DecidedByName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
public decimal Delta { get; set; }
@ -26,6 +32,14 @@ namespace SeaHaven.Services.DTOs
public DateTime? ExpiresAt { get; set; }
public string? NotificationStatus { get; set; }
public string? NotificationError { get; set; }
public int AttachmentCount { get; set; }
public bool WorkOrderClosed { get; set; }
// Approved-on-WO exposure totals for this item's work order: auto-approved and
// admin-approved amounts; pending, rejected, cancelled/withdrawn, and revoked
// requests are excluded.
public decimal WorkOrderAutoApprovedTotal { get; set; }
public decimal WorkOrderAdminApprovedTotal { get; set; }
public decimal WorkOrderApprovedExposureTotal { get; set; }
}
public class UpliftListResultDTO
@ -33,6 +47,7 @@ namespace SeaHaven.Services.DTOs
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public decimal PendingExposureTotal { get; set; }
public IEnumerable<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>();
}

View file

@ -84,6 +84,8 @@ namespace SeaHaven.Services.DTOs
public DateOnly? OriginalDate { get; set; }
public DateOnly? OriginalWeek { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
public int? ServiceId { get; set; }
public string? ServiceNameSnapshot { get; set; }
public bool IsPastDue { get; set; }
public string? SiteCode { get; set; }
public string? LocationName { get; set; }

View file

@ -30,6 +30,7 @@ namespace SeaHaven.Services.DTOs
public string? Trade { get; set; }
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
public string? PrimaryService { get; set; }
public int? ServiceId { get; set; }
public List<string>? ExtraServices { get; set; }
public string? ServiceNotes { get; set; }
/// <summary>Per-WO Aveta compliance. Omit or false = not required.</summary>
@ -73,6 +74,7 @@ namespace SeaHaven.Services.DTOs
/// </summary>
public bool UnscheduledOnly { get; set; }
public List<string>? Sites { get; set; }
public List<string>? Regions { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
public bool Overdue { get; set; }

View file

@ -0,0 +1,12 @@
namespace SeaHaven.Services.Exceptions
{
public class ServicesRegistryValidationException : Exception
{
public ServicesRegistryValidationException(string code, string message) : base(message)
{
Code = code;
}
public string Code { get; }
}
}

View file

@ -61,9 +61,26 @@ public static class DashboardMetrics
dueCount,
completedDueCount,
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2));
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2),
BuildStatusDistribution(workOrders));
}
private static IReadOnlyList<DashboardStatusBucketDTO> BuildStatusDistribution(
IReadOnlyList<DashboardWorkOrder> workOrders)
=> workOrders
.GroupBy(workOrder => workOrder.LifecycleStatus
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus))
.Select(group => new DashboardStatusBucketDTO
{
Status = group.Key.HasValue
? LifecycleStatusMapper.ToFeLabel(group.Key.Value)
: "Unknown",
Count = group.Count()
})
.OrderByDescending(bucket => bucket.Count)
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
.ToList();
private static bool IsPastDue(
DashboardWorkOrder workOrder,
LifecycleStatus? status,
@ -110,4 +127,5 @@ public sealed record DashboardMetricResult(
int DueCount,
int CompletedDueCount,
decimal CompletionRate,
decimal? AverageResolutionDays);
decimal? AverageResolutionDays,
IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution);

View file

@ -1,20 +1,25 @@
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.Services.Helpers;
public static class DashboardRegions
{
public static readonly IReadOnlyList<string> Names =
["East", "Central", "West", "California", "Unmapped/Other"];
public static readonly IReadOnlyList<string> Names = WorkOrderBoardRegions.Names;
public static string Resolve(string? state)
{
if (string.IsNullOrWhiteSpace(state))
var code = UsStateCodes.ToCode(state);
if (code is null)
return "Unmapped/Other";
return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region)
? region
: "Unmapped/Other";
foreach (var region in Names.Where(region => region != "Unmapped/Other"))
{
if (WorkOrderBoardRegions.StorageValuesFor(region).Contains(code))
return region;
}
return "Unmapped/Other";
}
public static IReadOnlyDictionary<string, int> Count(
@ -26,59 +31,4 @@ public static class DashboardRegions
return counts;
}
private static readonly IReadOnlyDictionary<string, string> StateToRegion =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["CT"] = "East",
["DE"] = "East",
["GA"] = "East",
["ME"] = "East",
["MD"] = "East",
["MA"] = "East",
["NH"] = "East",
["NJ"] = "East",
["NY"] = "East",
["NC"] = "East",
["PA"] = "East",
["RI"] = "East",
["SC"] = "East",
["VT"] = "East",
["VA"] = "East",
["WV"] = "East",
["AL"] = "Central",
["AR"] = "Central",
["FL"] = "Central",
["IL"] = "Central",
["IN"] = "Central",
["IA"] = "Central",
["KS"] = "Central",
["KY"] = "Central",
["LA"] = "Central",
["MI"] = "Central",
["MN"] = "Central",
["MS"] = "Central",
["MO"] = "Central",
["NE"] = "Central",
["ND"] = "Central",
["OH"] = "Central",
["OK"] = "Central",
["SD"] = "Central",
["TN"] = "Central",
["WI"] = "Central",
["AK"] = "West",
["AZ"] = "West",
["CO"] = "West",
["HI"] = "West",
["ID"] = "West",
["MT"] = "West",
["NV"] = "West",
["NM"] = "West",
["OR"] = "West",
["TX"] = "West",
["UT"] = "West",
["WA"] = "West",
["WY"] = "West",
["CA"] = "California"
};
}

View file

@ -56,8 +56,29 @@ namespace SeaHaven.Services.Helpers
["WY"] = "WYOMING"
};
private static readonly IReadOnlyDictionary<string, string> CodesByName =
Names.ToDictionary(pair => pair.Value, pair => pair.Key, StringComparer.Ordinal);
public static IReadOnlySet<string> All { get; } = new HashSet<string>(Names.Keys, StringComparer.Ordinal);
/// <summary>
/// Normalises a stored state value to its two-letter postal code. Accepts either a
/// postal code ("IN") or a full state name ("Indiana"), case-insensitively, matching
/// the storage forms produced by <see cref="ExpandStorageValues"/>. Returns null when
/// the value is blank or is not a recognised US state.
/// </summary>
public static string? ToCode(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var normalized = value.Trim().ToUpperInvariant();
if (All.Contains(normalized))
return normalized;
return CodesByName.TryGetValue(normalized, out var code) ? code : null;
}
public static IReadOnlyCollection<string> ExpandStorageValues(IEnumerable<string> codes)
{
return codes

View file

@ -17,6 +17,7 @@ namespace SeaHaven.Services.Helpers
public const string DocStatus = "docStatus";
public const string CompletedDate = "completedDate";
public const string Pm = "pm";
public const string ServiceId = "serviceId";
public const string ServiceNotes = "serviceNotes";
public const string ExtraServices = "extraServices";
public const string AdditionalContacts = "additionalContacts";
@ -49,6 +50,7 @@ namespace SeaHaven.Services.Helpers
DocStatus,
CompletedDate,
Pm,
ServiceId,
ServiceNotes,
ExtraServices,
AdditionalContacts,
@ -93,6 +95,7 @@ namespace SeaHaven.Services.Helpers
DocStatus => "DocStatus",
CompletedDate => "CompletedDate",
Pm => "Trade",
ServiceId => "ServiceId",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
AdditionalContacts => "AdditionalContacts",

View file

@ -28,13 +28,14 @@ namespace SeaHaven.Services.Implementation
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var dispatcherId = ResolveDispatcherScope(user, query);
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
var kpis = await _dataService.GetKpiCountsAsync(
accountId,
dispatcherId,
DashboardBusinessTime.Today(),
cancellationToken);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId,
dispatcherId,
@ -51,11 +52,15 @@ namespace SeaHaven.Services.Implementation
Open = useMetrics ? metrics.Open : counts.Open,
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
Completed = useMetrics ? metrics.Completed : counts.Completed,
ScheduledTomorrow = kpis.ScheduledTomorrow,
PendingUplifts = kpis.PendingUplifts,
AvetaPending = kpis.AvetaPending,
Breakdown = metrics.Breakdown,
DueCount = metrics.DueCount,
CompletedDueCount = metrics.CompletedDueCount,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
AverageResolutionDays = metrics.AverageResolutionDays,
StatusDistribution = metrics.StatusDistribution
};
}
@ -69,11 +74,7 @@ namespace SeaHaven.Services.Implementation
throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var dispatcherId = ResolveDispatcherScope(user, query);
var rows = await _dataService.GetDispatcherWorkloadAsync(
accountId,
@ -115,11 +116,7 @@ namespace SeaHaven.Services.Implementation
throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var dispatcherId = ResolveDispatcherScope(user, query);
var directory = await _dataService.GetDispatcherWorkloadAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
@ -137,7 +134,9 @@ namespace SeaHaven.Services.Implementation
DispatcherName = dispatcher.DispatcherName,
Color = dispatcher.Color,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
AverageResolutionDays = metrics.AverageResolutionDays,
AssignedCount = metrics.Total,
CompletedCount = metrics.Completed
};
})
.OrderByDescending(row => row.CompletionRate)
@ -161,11 +160,7 @@ namespace SeaHaven.Services.Implementation
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var dispatcherId = ResolveDispatcherScope(user, query);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
@ -188,11 +183,7 @@ namespace SeaHaven.Services.Implementation
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var dispatcherId = ResolveDispatcherScope(user, query);
var today = DashboardBusinessTime.Today();
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
@ -396,5 +387,33 @@ namespace SeaHaven.Services.Implementation
TotalVendors = rows.Count
};
}
private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query)
{
if (user.IsInRole("Dispatcher"))
{
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return dispatcherId;
}
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
{
var selected = query.DispatcherId;
if (string.IsNullOrWhiteSpace(selected))
return null;
if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase))
{
var identity = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(identity))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return identity;
}
return selected;
}
throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role.");
}
}
}

View file

@ -166,11 +166,15 @@ namespace SeaHaven.Services.Implementation
int pageSize,
string? search,
string? states = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string? sortBy = null,
string? sortDirection = null)
{
var stateFilter = NormalizeStateFilter(states);
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
var sort = NormalizeSortColumn(sortBy);
var direction = NormalizeSortDirection(sortDirection);
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken, sort, direction);
// SH-138: one bounded contact read for the whole page, never per row.
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
@ -184,6 +188,42 @@ namespace SeaHaven.Services.Implementation
};
}
internal static readonly IReadOnlyCollection<string> SortableColumns = new[] { "code", "client", "address", "city", "state", "poc" };
internal static string? NormalizeSortColumn(string? sortBy)
{
if (string.IsNullOrWhiteSpace(sortBy))
return null;
var normalized = sortBy.Trim().ToLowerInvariant();
if (!SortableColumns.Contains(normalized))
{
throw new ValidationException(new[]
{
new ValidationFailure("sortBy", $"sortBy must be one of: {string.Join(", ", SortableColumns)}.")
});
}
return normalized;
}
internal static string NormalizeSortDirection(string? sortDirection)
{
if (string.IsNullOrWhiteSpace(sortDirection))
return "asc";
var normalized = sortDirection.Trim().ToLowerInvariant();
if (normalized != "asc" && normalized != "desc")
{
throw new ValidationException(new[]
{
new ValidationFailure("sortDirection", "sortDirection must be asc or desc.")
});
}
return normalized;
}
internal static IReadOnlyCollection<string>? NormalizeStateFilter(string? states)
{
if (string.IsNullOrWhiteSpace(states))
@ -503,6 +543,7 @@ namespace SeaHaven.Services.Implementation
Email = location.Email,
Status = location.Status,
AccountId = location.AccountId,
AccountName = location.Account?.Name,
CreatedDate = location.CreatedDate,
CreatedBy = location.createdby,
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)

View file

@ -0,0 +1,255 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class ServicesRegistryService : IServicesRegistryService
{
// Product contract: the registry only accepts the three work-order types
// the product currently supports. Any other WorkOrderType is rejected.
public static readonly IReadOnlySet<WorkOrderType> AllowedWorkOrderTypes =
new HashSet<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
private static readonly WorkOrderType[] DefaultSupportedWorkOrderTypes =
{ WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
public const string SchedulerRole = "Scheduler";
public const string AdminRole = "Admin";
private const int NameMaxLength = 200;
private const int IconKeyMaxLength = 64;
private readonly IServicesRegistryDataService _dataService;
public ServicesRegistryService(IServicesRegistryDataService dataService)
{
_dataService = dataService;
}
public async Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
ValidateWorkOrderTypeFilter(workOrderType);
var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken);
return rows.Select(MapToDto).ToList();
}
public async Task<ServiceDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
return row == null ? null : MapToDto(row);
}
public async Task<ServiceDto> CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken)
{
EnsureCanMutate(user);
var name = RequireValidName(input.Name);
var trade = RequireCanonicalTrade(input.Trade);
var iconKey = RequireValidIconKey(input.IconKey);
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), null, cancellationToken))
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
var service = new Service
{
Name = name,
NormalizedName = TradeCatalog.Normalize(name),
Trade = trade,
IconKey = iconKey,
RequiresCompletionDocument = input.RequiresCompletionDocument,
CompletionDocTemplateId = template?.Id,
IsActive = true,
SupportedWorkOrderTypes = supportedTypes
.Select(t => new ServiceWorkOrderType { WorkOrderType = t })
.ToList()
};
try
{
await _dataService.AddServiceAsync(service, cancellationToken);
}
catch (ServicesRegistryDuplicateNameException)
{
throw DuplicateName();
}
var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken);
return MapToDto(row!);
}
public async Task<ServiceDto> UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken)
{
EnsureCanMutate(user);
var service = await _dataService.GetServiceForUpdateAsync(id, cancellationToken);
if (service == null || service.IsDeleted == true)
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
var name = RequireValidName(input.Name);
var trade = RequireCanonicalTrade(input.Trade);
var iconKey = RequireValidIconKey(input.IconKey);
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), id, cancellationToken))
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
service.Name = name;
service.NormalizedName = TradeCatalog.Normalize(name);
service.Trade = trade;
service.IconKey = iconKey;
service.RequiresCompletionDocument = input.RequiresCompletionDocument;
service.CompletionDocTemplateId = template?.Id;
if (input.IsActive.HasValue)
{
EnsureCanChangeActiveState(user);
service.IsActive = input.IsActive.Value;
}
try
{
await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken);
}
catch (ServicesRegistryDuplicateNameException)
{
throw DuplicateName();
}
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
return MapToDto(row!);
}
public async Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
{
if (user is null || !user.IsInRole(AdminRole))
throw new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.");
var deactivated = await _dataService.DeactivateServiceAsync(id, cancellationToken);
if (!deactivated)
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
}
private static void EnsureCanMutate(ClaimsPrincipal user)
{
if (user is null || !(user.IsInRole(SchedulerRole) || user.IsInRole(AdminRole)))
throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.");
}
private static void EnsureCanChangeActiveState(ClaimsPrincipal user)
{
if (user is null || !user.IsInRole(AdminRole))
throw new ServicesRegistryValidationException("Forbidden", "Only administrators can change service active state.");
}
private static ServicesRegistryValidationException DuplicateName() =>
new("DuplicateName", "A service with this name already exists.");
private static string RequireValidName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ServicesRegistryValidationException("NameRequired", "Service name is required.");
var trimmed = name.Trim();
if (trimmed.Length > NameMaxLength)
throw new ServicesRegistryValidationException("NameInvalid", $"Service name cannot exceed {NameMaxLength} characters.");
return trimmed;
}
private static string RequireCanonicalTrade(string? trade)
{
if (string.IsNullOrWhiteSpace(trade))
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
var normalized = TradeCatalog.Normalize(trade);
var canonical = TradeCatalog.CanonicalTrades
.FirstOrDefault(t => TradeCatalog.Normalize(t) == normalized);
if (canonical == null)
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
return canonical;
}
private static string RequireValidIconKey(string? iconKey)
{
if (string.IsNullOrWhiteSpace(iconKey))
throw new ServicesRegistryValidationException("IconKeyInvalid", "Icon key is required.");
var trimmed = iconKey.Trim();
if (trimmed.Length > IconKeyMaxLength)
throw new ServicesRegistryValidationException("IconKeyInvalid", $"Icon key cannot exceed {IconKeyMaxLength} characters.");
return trimmed;
}
private static List<WorkOrderType> RequireValidSupportedTypes(IEnumerable<WorkOrderType>? supportedTypes)
{
// The registry form does not expose type categories yet. Until it
// does, an omitted or empty category list keeps a service available
// to the three supported work-order types.
var types = supportedTypes?.Distinct().ToList();
if (types == null || types.Count == 0)
return DefaultSupportedWorkOrderTypes.ToList();
if (types.Any(t => !AllowedWorkOrderTypes.Contains(t)))
throw new ServicesRegistryValidationException(
"WorkOrderTypeInvalid",
"Supported work order types may only include PM, Reactive, or Emergency.");
return types;
}
private static void ValidateWorkOrderTypeFilter(WorkOrderType? workOrderType)
{
if (workOrderType.HasValue && !AllowedWorkOrderTypes.Contains(workOrderType.Value))
throw new ServicesRegistryValidationException(
"WorkOrderTypeInvalid",
"Supported work order types may only include PM, Reactive, or Emergency.");
}
private async Task<LinkedTemplateSummary?> ResolveTemplateLinkAsync(ServiceInput input, CancellationToken cancellationToken)
{
// Toggle off clears/ignores any template link.
if (!input.RequiresCompletionDocument || input.CompletionDocTemplateId is not int templateId)
return null;
var template = await _dataService.GetActiveTemplateSummaryAsync(templateId, cancellationToken);
if (template == null)
throw new ServicesRegistryValidationException(
"TemplateInvalid",
"Completion document template must reference an active, non-deleted template.");
return template;
}
private static ServiceDto MapToDto(ServiceRegistryRow row)
{
return new ServiceDto
{
Id = row.Id,
Name = row.Name,
Trade = row.Trade,
IconKey = row.IconKey,
RequiresCompletionDocument = row.RequiresCompletionDocument,
CompletionDocTemplate = row.CompletionDocTemplateId == null
? null
: new LinkedTemplateSummaryDto
{
Id = row.CompletionDocTemplateId.Value,
Name = row.CompletionDocTemplateName ?? ""
},
IsActive = row.IsActive,
SupportedWorkOrderTypes = row.SupportedWorkOrderTypes.OrderBy(t => (int)t).ToList()
};
}
}
}

View file

@ -15,24 +15,28 @@ namespace SeaHaven.Services.Implementation
private readonly IVendorDocumentStoragePort _documentStorage;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
private readonly IWorkOrderUpliftService? _workOrderUpliftService;
public UpliftService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IVendorDocumentStoragePort documentStorage,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
IOptions<ApprovalsOptions> approvalsOptions,
IWorkOrderUpliftService? workOrderUpliftService = null)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_documentStorage = documentStorage;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
_workOrderUpliftService = workOrderUpliftService;
}
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 mapped = items.Select(r => new UpliftListItemDTO
{
@ -42,6 +46,11 @@ namespace SeaHaven.Services.Implementation
PONumber = r.PONumber,
WorkOrderId = r.WorkOrderId,
VendorCompanyName = r.VendorCompanyName,
WorkOrderNumber = r.WorkOrderNumber,
WorkOrderSite = r.WorkOrderSiteCode,
WorkOrderService = r.WorkOrderService,
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
@ -58,14 +67,37 @@ namespace SeaHaven.Services.Implementation
EvidenceSizeBytes = r.EvidenceSizeBytes,
ExpiresAt = r.ExpiresAt,
NotificationStatus = r.NotificationStatus,
NotificationError = r.NotificationError
NotificationError = r.NotificationError,
AttachmentCount = r.AttachmentCount,
WorkOrderClosed = r.WorkOrderClosed
}).ToList();
// Approved-on-WO exposure totals are aggregated once for the whole page
// (single set-based data-service call) and then attached per item.
var workOrderIds = mapped
.Where(i => i.WorkOrderId.HasValue)
.Select(i => i.WorkOrderId!.Value)
.Distinct()
.ToList();
var exposureByWorkOrder = (await _upliftData
.GetApprovedExposureForWorkOrdersAsync(workOrderIds, cancellationToken))
.ToDictionary(e => e.WorkOrderId);
foreach (var item in mapped)
{
if (!item.WorkOrderId.HasValue) continue;
if (!exposureByWorkOrder.TryGetValue(item.WorkOrderId.Value, out var exposure)) continue;
item.WorkOrderAutoApprovedTotal = exposure.AutoApprovedTotal;
item.WorkOrderAdminApprovedTotal = exposure.AdminApprovedTotal;
item.WorkOrderApprovedExposureTotal = exposure.AutoApprovedTotal + exposure.AdminApprovedTotal;
}
return new UpliftListResultDTO
{
Total = total,
Page = page,
PageSize = pageSize,
PendingExposureTotal = pendingExposureTotal,
Items = mapped
};
}
@ -156,6 +188,41 @@ namespace SeaHaven.Services.Implementation
public Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
=> RejectInternalAsync(user, id, note, "reject", cancellationToken);
public async Task<UpliftDecisionResultDTO> RevokeAsync(
ClaimsPrincipal user,
int id,
string reason,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(reason))
throw new InvalidOperationException("A reason is required when revoking an approved uplift");
var request = await _upliftData.GetByIdAsync(id, cancellationToken);
if (request == null)
throw new KeyNotFoundException("Uplift request not found");
if (!string.Equals(UpliftStatus.ToCanonical(request.Status), UpliftStatus.Approved, StringComparison.Ordinal))
throw new InvalidOperationException($"Cannot revoke a '{UpliftStatus.ToCanonical(request.Status)}' uplift request");
if (!user.IsInRole("Admin"))
throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift");
if (_workOrderUpliftService == null)
throw new InvalidOperationException("The work-order uplift flow is unavailable");
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(id, cancellationToken);
if (!workOrderId.HasValue)
throw new KeyNotFoundException("Work order not found");
var revoked = await _workOrderUpliftService.RevokeAsync(
workOrderId.Value,
id,
new RevokeWorkOrderUpliftRequestDto { Reason = reason.Trim() },
user,
cancellationToken);
if (revoked == null)
throw new KeyNotFoundException("Work order not found");
return new UpliftDecisionResultDTO { Id = revoked.Id, Status = UpliftStatus.Revoked };
}
private async Task<UpliftDecisionResultDTO> RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(note))
@ -275,6 +342,19 @@ namespace SeaHaven.Services.Implementation
return false;
}
// Approval queue read contract: the requester label prefers the recorded
// requester (vendor or internal display name captured at creation) and falls
// back to the creating user's name resolved server-side.
private static string? ResolveRequestedByName(string? vendorName, string? firstName, string? lastName)
{
if (!string.IsNullOrWhiteSpace(vendorName))
return vendorName;
var composed = string.Join(" ", new[] { firstName, lastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
return composed.Length > 0 ? composed : null;
}
private string[] RolesForTier(int requiredTier) => requiredTier switch
{
1 => _approvalsOptions.Tier1Roles,

View file

@ -49,6 +49,7 @@ namespace SeaHaven.Services.Implementation
dateRange.To,
query.UnscheduledOnly,
query.Sites,
query.Regions,
query.Types,
query.Overdue,
query.Dispatchers,

View file

@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderBoardCreateValidation _validator;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IServicesRegistryService? _servicesRegistryService;
public WorkOrderBoardCreateService(
IWorkOrderBoardDataService boardDataService,
@ -27,7 +28,8 @@ namespace SeaHaven.Services.Implementation
IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService,
IWorkOrderBoardCreateValidation validator,
IWorkOrderAccountResolver accountResolver)
IWorkOrderAccountResolver accountResolver,
IServicesRegistryService? servicesRegistryService = null)
{
_boardDataService = boardDataService;
_mutationData = mutationData;
@ -35,6 +37,7 @@ namespace SeaHaven.Services.Implementation
_auditService = auditService;
_validator = validator;
_accountResolver = accountResolver;
_servicesRegistryService = servicesRegistryService;
}
public async Task<WorkOrderBoardRowDto> CreateAsync(
@ -54,7 +57,8 @@ namespace SeaHaven.Services.Implementation
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
var siteCode = request.SiteCode!.Trim();
var primaryService = ResolvePrimaryService(request);
var selectedService = await ResolveServiceAsync(request.ServiceId, request.WorkOrderType, request.PrimaryService ?? request.Trade, cancellationToken);
var primaryService = selectedService?.Name ?? ResolvePrimaryService(request);
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
var serviceNotes = TrimOrNull(request.ServiceNotes);
var pocName = TrimOrNull(request.PocName);
@ -87,6 +91,8 @@ namespace SeaHaven.Services.Implementation
AccountId = accountId,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Trade = primaryService,
ServiceId = selectedService?.Id,
ServiceNameSnapshot = selectedService?.Name,
ExtraServices = extraServicesJson,
ServiceNotes = serviceNotes,
AvetaRequired = request.AvetaRequired == true,
@ -138,6 +144,8 @@ namespace SeaHaven.Services.Implementation
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
if (workOrder.Trade != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
if (workOrder.ServiceId.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceId", "", workOrder.ServiceId.Value.ToString()));
if (workOrder.ExtraServices != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
if (workOrder.ServiceNotes != null)
@ -251,6 +259,46 @@ namespace SeaHaven.Services.Implementation
return null;
}
private async Task<ServiceDto?> ResolveServiceAsync(
int? serviceId,
WorkOrderType workOrderType,
string? legacyLabel,
CancellationToken cancellationToken)
{
if (_servicesRegistryService == null)
{
if (serviceId.HasValue)
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId is not available.");
return null;
}
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType))
{
if (serviceId.HasValue)
throw new WorkOrderBoardValidationException(
"ServiceInvalid",
"serviceId is not supported for this work order type.");
return null;
}
var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken);
if (serviceId.HasValue)
{
var selected = services.FirstOrDefault(service => service.Id == serviceId.Value);
if (selected == null)
throw new WorkOrderBoardValidationException(
"ServiceInvalid",
"serviceId must reference an active service that supports this work order type.");
return selected;
}
if (string.IsNullOrWhiteSpace(legacyLabel))
return null;
return services.FirstOrDefault(service =>
string.Equals(service.Name.Trim(), legacyLabel.Trim(), StringComparison.OrdinalIgnoreCase));
}
private static string? SerializeExtraServices(List<string>? extras)
{
if (extras == null || extras.Count == 0)

View file

@ -108,6 +108,8 @@ namespace SeaHaven.Services.Implementation
OriginalDate = row.OriginalDate,
OriginalWeek = row.OriginalWeek,
WorkOrderType = row.WorkOrderType,
ServiceId = row.ServiceId,
ServiceNameSnapshot = row.ServiceNameSnapshot,
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
SiteCode = row.SiteCode,
LocationName = row.LocationName,
@ -136,7 +138,7 @@ namespace SeaHaven.Services.Implementation
TechPhone = row.TechPhone,
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
Pm = row.Trade ?? row.Problem,
Pm = row.Trade ?? row.ServiceNameSnapshot ?? row.Problem,
ServiceNotes = row.ServiceNotes,
ExtraServices = ParseExtraServices(row.ExtraServices),
AdditionalContacts = MapAdditionalContacts(row),

View file

@ -16,15 +16,18 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderBoardDataService _boardDataService;
private readonly IWorkOrderBoardMutationDataService _mutationData;
private readonly IWorkOrderAuditService _auditService;
private readonly IServicesRegistryService? _servicesRegistryService;
public WorkOrderBoardUpdateService(
IWorkOrderBoardDataService boardDataService,
IWorkOrderBoardMutationDataService mutationData,
IWorkOrderAuditService auditService)
IWorkOrderAuditService auditService,
IServicesRegistryService? servicesRegistryService = null)
{
_boardDataService = boardDataService;
_mutationData = mutationData;
_auditService = auditService;
_servicesRegistryService = servicesRegistryService;
}
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
@ -73,6 +76,13 @@ namespace SeaHaven.Services.Implementation
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
if (canonicalField.Equals(WorkOrderBoardFieldNames.WorkOrderType, StringComparison.OrdinalIgnoreCase)
&& workOrder.ServiceId.HasValue)
{
var nextType = ParseWorkOrderType(value: request.Value);
await EnsureServiceSupportsTypeAsync(workOrder.ServiceId.Value, nextType, ct);
}
Dispatch? dispatch = null;
ResolvedDispatch? resolved = null;
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
@ -181,6 +191,7 @@ namespace SeaHaven.Services.Implementation
{
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
WorkOrderBoardFieldNames.ServiceId => await ApplyServiceIdAsync(workOrder, value, auditField, cancellationToken),
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
@ -388,6 +399,83 @@ namespace SeaHaven.Services.Implementation
return changes;
}
private async Task<List<FieldChange>> ApplyServiceIdAsync(
WorkOrder workOrder,
string? value,
string auditField,
CancellationToken cancellationToken)
{
if (_servicesRegistryService == null)
throw new WorkOrderBoardValidationException("ServiceInvalid", "Service registry is unavailable.");
if (string.IsNullOrWhiteSpace(value))
{
var previousServiceId = workOrder.ServiceId;
var previousServiceLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade;
workOrder.ServiceId = null;
workOrder.ServiceNameSnapshot = null;
workOrder.Trade = null;
return new List<FieldChange>
{
FieldChange.ForField(auditField, previousServiceId?.ToString(), null),
FieldChange.ForField("Trade", previousServiceLabel, null)
};
}
if (!int.TryParse(value.Trim(), out var serviceId) || serviceId <= 0)
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)
|| service == null
|| !service.IsActive
|| !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM))
{
throw new WorkOrderBoardValidationException(
"ServiceInvalid",
"serviceId must reference an active service that supports this work order type.");
}
var oldId = workOrder.ServiceId;
var oldLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade;
workOrder.ServiceId = service.Id;
workOrder.ServiceNameSnapshot = service.Name;
workOrder.Trade = service.Name;
return new List<FieldChange>
{
oldId == service.Id
? FieldChange.Unchanged(auditField)
: FieldChange.ForField(auditField, oldId?.ToString(), service.Id.ToString()),
oldLabel == service.Name
? FieldChange.Unchanged("Trade")
: FieldChange.ForField("Trade", oldLabel, service.Name)
};
}
private async Task EnsureServiceSupportsTypeAsync(int serviceId, WorkOrderType workOrderType, CancellationToken cancellationToken)
{
if (_servicesRegistryService == null)
throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType))
throw new WorkOrderBoardValidationException(
"ServiceTypeInvalid",
"The selected service does not support this work order type.");
}
private static WorkOrderType ParseWorkOrderType(string? value)
{
if (string.IsNullOrWhiteSpace(value)
|| string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)
|| !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
{
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
}
return parsed;
}
private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField)
{
if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized))

View file

@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces
Task<bool> LocationExistsAsync(int id);
Task<int> GetTotalLocationCountAsync();
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default);
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default, string? sortBy = null, string? sortDirection = null);
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);

View file

@ -0,0 +1,19 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
// Global Services registry. Tenant scope is global (no AccountId),
// matching DropdownOptions. Reads are authenticated; create/update require
// the Scheduler or Admin role and deactivation is Admin-only, enforced at
// this service entry — never in the controller alone.
public interface IServicesRegistryService
{
Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken);
Task<ServiceDto?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<ServiceDto> CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken);
Task<ServiceDto> UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken);
Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
}
}

View file

@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
// SH-101: canonical reject (alias of deny; writes Rejected).
Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
Task<UpliftDecisionResultDTO> RevokeAsync(ClaimsPrincipal user, int id, string reason, CancellationToken cancellationToken);
// SH-101: internal request-changes route (note + tier authorization + audit).
Task<UpliftDecisionResultDTO> RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken);
// SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request.

View file

@ -0,0 +1,342 @@
using System.Reflection;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Migrations;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Xunit;
namespace SeaHavenIndustries.Tests;
// Shape evidence for the global Services registry migration (table,
// indexes, FK, idempotent additive seed) plus a SQL Server execution test that
// is silently skipped when no provider is reachable, mirroring existing
// provider-gated migration tests.
public class SH303ServicesRegistryMigrationTests
{
private const string LocalDbMaster =
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
[Fact]
public void Up_CreatesRegistryTablesUniqueIndexesFkAndIdempotentProblemSeed()
{
var operations = BuildOperations("Up");
var createTables = operations.OfType<CreateTableOperation>().ToList();
Assert.Equal(
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
createTables.Select(t => t.Name).ToHashSet());
var services = createTables.Single(t => t.Name == "Services");
Assert.Contains(services.Columns, c => c.Name == "NormalizedName" && (string)c.ColumnType! == "nvarchar(200)");
Assert.Contains(services.Columns, c => c.Name == "CompletionDocTemplateId" && c.IsNullable);
Assert.Contains(services.Columns, c => c.Name == "RequiresCompletionDocument" && !c.IsNullable);
Assert.Contains(services.Columns, c => c.Name == "IsActive" && !c.IsNullable);
var foreignKeys = services.ForeignKeys.Concat(
createTables.Single(t => t.Name == "ServiceWorkOrderTypes").ForeignKeys)
.ToList();
Assert.Contains(foreignKeys, fk =>
fk.Table == "Services"
&& fk.PrincipalTable == "CompletionDocTemplates"
&& fk.OnDelete == ReferentialAction.Restrict);
Assert.Contains(foreignKeys, fk =>
fk.Table == "ServiceWorkOrderTypes" && fk.PrincipalTable == "Services");
var indexes = operations.OfType<CreateIndexOperation>().ToList();
Assert.Contains(indexes, i =>
i.Table == "Services" && i.Columns.SequenceEqual(new[] { "NormalizedName" }) && i.IsUnique);
Assert.Contains(indexes, i =>
i.Table == "ServiceWorkOrderTypes"
&& i.Columns.SequenceEqual(new[] { "ServiceId", "WorkOrderType" })
&& i.IsUnique);
// The seed is raw additive SQL: read-only over DropdownOptions, idempotent
// via NormalizedName NOT EXISTS, and never mutates dropdown rows.
var seedSql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
Assert.Contains("[Category] = N'Problem'", seedSql);
Assert.Contains("[IsActive] = 1", seedSql);
Assert.Contains("NOT EXISTS", seedSql);
Assert.Contains("INSERT INTO [Services]", seedSql);
Assert.Contains("INSERT INTO [ServiceWorkOrderTypes]", seedSql);
Assert.DoesNotContain("UPDATE [DropdownOptions]", seedSql);
Assert.DoesNotContain("DELETE FROM [DropdownOptions]", seedSql);
Assert.DoesNotContain("DELETE [DropdownOptions]", seedSql);
}
[Fact]
public void Down_DropsRegistryTables()
{
var operations = BuildOperations("Down");
var drops = operations.OfType<DropTableOperation>().Select(d => d.Name).ToList();
Assert.Equal(
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
drops.ToHashSet());
Assert.Empty(operations.OfType<SqlOperation>());
}
[Fact]
public void SH187_Up_AddsNullableServiceLinkAndBackfillsLegacyLabels()
{
var operations = BuildOperations<SH187_WorkOrderServiceLink>("Up");
var columns = operations.OfType<AddColumnOperation>().ToList();
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceId" && c.IsNullable);
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceNameSnapshot"
&& c.IsNullable && c.MaxLength == 200);
Assert.Contains(operations.OfType<CreateIndexOperation>(), i =>
i.Table == "workOrders" && i.Columns.SequenceEqual(new[] { "ServiceId" }));
Assert.Contains(operations.OfType<AddForeignKeyOperation>(), fk =>
fk.Table == "workOrders"
&& fk.PrincipalTable == "Services"
&& fk.OnDelete == ReferentialAction.Restrict);
var sql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
Assert.Contains("UPDATE w", sql);
Assert.Contains("ServiceNameSnapshot", sql);
Assert.Contains("LEFT JOIN [Services]", sql);
}
[Fact]
public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable()
{
var masterConnectionString = await ResolveMasterConnectionStringAsync();
if (masterConnectionString == null)
return;
var dbName = $"SH303Registry_{Guid.NewGuid():N}";
var connectionString = WithDatabase(masterConnectionString, dbName);
try
{
await CreateDatabaseAsync(masterConnectionString, dbName);
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using (var seedSource = connection.CreateCommand())
{
seedSource.CommandText =
"""
CREATE TABLE DropdownOptions (
Id int NOT NULL IDENTITY PRIMARY KEY,
Category nvarchar(max) NOT NULL,
Value nvarchar(max) NOT NULL,
ParentValue nvarchar(max) NULL,
SortOrder int NOT NULL,
IsActive bit NOT NULL
);
CREATE TABLE CompletionDocTemplates (
Id int NOT NULL IDENTITY PRIMARY KEY,
Name nvarchar(200) NOT NULL,
ServiceKey nvarchar(max) NOT NULL,
TemplateUrl nvarchar(max) NOT NULL,
IsActive bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL
);
INSERT INTO DropdownOptions (Category, Value, SortOrder, IsActive) VALUES
(N'Problem', N'Leak', 0, 1),
(N'Problem', N'leak', 1, 1),
(N'Problem', N'No Power', 2, 1),
(N'Problem', N'Not Cooling', 3, 1),
(N'Problem', N'Not Heating', 4, 1),
(N'Problem', N'Alarm Fault', 5, 1),
(N'Problem', N'Broken/Damaged', 6, 1),
(N'Problem', N'Retired Ghost', 7, 0),
(N'Trade', N'Electrical', 0, 1);
""";
await seedSource.ExecuteNonQueryAsync();
}
await using var context = new ApplicationDbContext(
new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlServer(connection).Options);
await ApplyMigrationAsync(context, new SH303_ServicesRegistry());
await using (var servicesCmd = connection.CreateCommand())
{
servicesCmd.CommandText =
"""
SELECT [Name], [Trade], [IconKey]
FROM [Services]
ORDER BY [Name];
""";
await using var reader = await servicesCmd.ExecuteReaderAsync();
var rows = new List<string>();
while (await reader.ReadAsync())
rows.Add($"{reader.GetString(0)}|{reader.GetString(1)}|{reader.GetString(2)}");
// Repeated case variants collapse onto one deterministic row and the
// inactive value plus the non-Problem category never seed.
var expected = new[]
{
"Alarm Fault|Fire & Life Safety|fire-life-safety",
"Broken/Damaged|General Building & Handyman|general-building-handyman",
"Leak|Plumbing & Water Systems|plumbing-water-systems",
"No Power|Electrical|electrical",
"Not Cooling|HVAC|hvac",
"Not Heating|HVAC|hvac"
};
Assert.Equal(expected, rows.OrderBy(r => r).ToArray());
}
await using (var typesCmd = connection.CreateCommand())
{
typesCmd.CommandText =
"""
SELECT COUNT(*)
FROM [ServiceWorkOrderTypes] t
JOIN [Services] s ON s.[Id] = t.[ServiceId]
WHERE t.[WorkOrderType] IN (2, 3, 6);
""";
Assert.Equal(18, Convert.ToInt32(await typesCmd.ExecuteScalarAsync())); // 6 services x 3 supported types
}
await using (var untouchedCmd = connection.CreateCommand())
{
untouchedCmd.CommandText = "SELECT COUNT(*) FROM [DropdownOptions];";
Assert.Equal(9, Convert.ToInt32(await untouchedCmd.ExecuteScalarAsync()));
}
// Re-running only the seed statements must be a no-op (idempotence).
var before = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
await ApplySeedStatementsAsync(context);
var after = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
Assert.Equal(before, after);
var migration = new SH303_ServicesRegistry();
await ApplyMigrationAsync(context, migration, "Down");
Assert.Equal(0, await CountAsync(connection, "SELECT COUNT(*) FROM sys.tables WHERE name IN ('Services', 'ServiceWorkOrderTypes');"));
await ApplyMigrationAsync(context, migration, "Up");
Assert.Equal(6, await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"));
Assert.Equal(18, await CountAsync(connection, "SELECT COUNT(*) FROM [ServiceWorkOrderTypes];"));
}
finally
{
await DropDatabaseAsync(masterConnectionString, dbName);
}
}
private static List<MigrationOperation> BuildOperations(string direction)
=> BuildOperations<SH303_ServicesRegistry>(direction);
private static List<MigrationOperation> BuildOperations<TMigration>(string direction)
where TMigration : Migration, new()
{
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(new TMigration(), [builder]);
return builder.Operations;
}
private static async Task ApplyMigrationAsync(ApplicationDbContext context, Migration migration, string direction = "Up")
{
var builder = new MigrationBuilder(context.Database.ProviderName!);
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(migration, [builder]);
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(builder.Operations, context.Model);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task ApplySeedStatementsAsync(ApplicationDbContext context)
{
var sqlOperations = BuildOperations("Up").OfType<SqlOperation>().ToList();
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(sqlOperations, context.Model);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task<int> CountAsync(SqlConnection connection, string commandText)
{
await using var command = connection.CreateCommand();
command.CommandText = commandText;
return Convert.ToInt32(await command.ExecuteScalarAsync());
}
private static async Task<string?> ResolveMasterConnectionStringAsync()
{
var configured = Environment.GetEnvironmentVariable("SH303_SQLSERVER_MASTER");
var candidate = !string.IsNullOrWhiteSpace(configured)
? WithDatabase(configured, "master")
: OperatingSystem.IsWindows() ? LocalDbMaster : null;
if (candidate == null)
return null;
try
{
await using var connection = new SqlConnection(candidate);
await connection.OpenAsync();
return candidate;
}
catch (SqlException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
}
private static async Task CreateDatabaseAsync(string masterConnectionString, string dbName)
{
await using var connection = new SqlConnection(masterConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE {QuoteSqlServerIdentifier(dbName)};";
await command.ExecuteNonQueryAsync();
}
private static async Task DropDatabaseAsync(string masterConnectionString, string dbName)
{
try
{
var quoted = QuoteSqlServerIdentifier(dbName);
await using var connection = new SqlConnection(masterConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText =
$"""
IF DB_ID(@dbName) IS NOT NULL
BEGIN
ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE {quoted};
END
""";
command.Parameters.AddWithValue("@dbName", dbName);
await command.ExecuteNonQueryAsync();
}
catch
{
// Best-effort cleanup for ephemeral SQL Server databases.
}
}
private static string QuoteSqlServerIdentifier(string name) =>
"[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
private static string WithDatabase(string connectionString, string database)
{
var builder = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = database
};
return builder.ConnectionString;
}
}

View file

@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
public Task<int> CountAsync() => _inner.CountAsync();
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken)
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken);
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null)
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
=> _inner.GetDetailByIdAsync(id, cancellationToken);
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)

View file

@ -431,16 +431,19 @@ 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();
public Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();

View file

@ -228,6 +228,47 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Equal("BK5", result.Items.First().SiteCode);
}
[Fact]
public async Task SearchAsync_FiltersByCanonicalRegionAndUnmappedBucket()
{
await using var context = CreateContext();
var date = new DateTime(2026, 6, 23);
context.Locations.AddRange(
new Locations { Id = 1, State = "WA" },
new Locations { Id = 2, State = "CALIFORNIA" },
new Locations { Id = 3, State = "Atlantis" },
new Locations { Id = 4, State = null });
context.workOrders.AddRange(
new WorkOrder { Id = 1, LocationId = 1, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 2, LocationId = 2, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 3, LocationId = 3, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 4, LocationId = 4, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled });
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
dataService, WorkOrderAccountTestHelpers.Resolver(context));
var query = new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2026, 6, 22),
DateTo = new DateOnly(2026, 6, 30),
PageSize = 50
};
query.Regions = new List<string> { "West" };
var west = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1 }, west.Items.Select(item => item.Id));
query.Regions = new List<string> { "California" };
var california = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 2 }, california.Items.Select(item => item.Id));
query.Regions = new List<string> { "Unmapped/Other" };
var unmapped = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 3, 4 }, unmapped.Items.Select(item => item.Id));
}
[Fact]
public async Task SearchAsync_ClampsPageSizeToMax()
{

View file

@ -1043,6 +1043,24 @@ public class WorkOrderBoardServiceTests
Assert.Equal("09:00", dto.ApptTime);
}
[Fact]
public void MapRawRow_PmEditTakesPrecedenceOverServiceSnapshot()
{
var row = RawRow(
scheduledDate: new DateTime(2026, 6, 23),
scheduledStart: null,
scheduledEnd: null,
dispatchApptDate: null) with
{
Trade = "Updated service label",
ServiceNameSnapshot = "Original service label"
};
var dto = WorkOrderBoardService.MapRawRow(row, DateTime.UtcNow);
Assert.Equal("Updated service label", dto.Pm);
}
private static WorkOrderBoardRawRow RawRow(
DateTime? scheduledDate,
DateTime? scheduledStart,
@ -1055,6 +1073,8 @@ public class WorkOrderBoardServiceTests
CarriedOver: 0,
IsAddOn: false,
WorkOrderType: null,
ServiceId: null,
ServiceNameSnapshot: null,
SiteCode: null,
LocationName: null,
PocName: null,