mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
23 commits
bce0f4b4e9
...
bfebd2515f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfebd2515f | ||
|
|
3e301ac9da | ||
|
|
564bd70301 | ||
|
|
f564e1b112 | ||
|
|
782165a4cf | ||
|
|
7e06d1c51b | ||
|
|
e07f04a6af | ||
|
|
b10956d731 | ||
|
|
7d728d9101 | ||
|
|
da80595321 | ||
|
|
2e983b1f6a | ||
|
|
65b04687bc | ||
|
|
b3e9a2eec3 | ||
|
|
162bb4d80e | ||
|
|
dab39e78be | ||
|
|
dca8d898cb | ||
|
|
c605ceefae | ||
|
|
e8feed1d1b | ||
|
|
22308f21f6 | ||
|
|
33f517620b | ||
|
|
dec8b15278 | ||
|
|
45f98e8154 | ||
|
|
06f9450485 |
36 changed files with 10829 additions and 42 deletions
|
|
@ -270,11 +270,13 @@ public class DashboardServiceTests
|
||||||
1,
|
1,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
result.TotalDispatchers.Should().Be(1);
|
result.TotalDispatchers.Should().Be(2);
|
||||||
result.Items.Should().ContainSingle();
|
result.Items.Select(item => item.DispatcherName)
|
||||||
result.Items[0].DispatcherName.Should().Be("Ada One");
|
.Should().ContainInOrder("Ada One", "Ben Two");
|
||||||
result.Items[0].TotalCount.Should().Be(2);
|
result.Items[0].TotalCount.Should().Be(2);
|
||||||
result.Items[0].OpenCount.Should().Be(1);
|
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);
|
result.PageSize.Should().Be(10);
|
||||||
|
|
||||||
var adminResult = await NewService(ctx).GetWorkloadAsync(
|
var adminResult = await NewService(ctx).GetWorkloadAsync(
|
||||||
|
|
@ -358,11 +360,17 @@ public class DashboardServiceTests
|
||||||
ctx.Locations.AddRange(
|
ctx.Locations.AddRange(
|
||||||
new Locations { Id = 1, State = "NY" },
|
new Locations { Id = 1, State = "NY" },
|
||||||
new Locations { Id = 2, State = "ca" },
|
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(
|
ctx.workOrders.AddRange(
|
||||||
new WorkOrder { AccountId = 1, LocationId = 1 },
|
new WorkOrder { AccountId = 1, LocationId = 1 },
|
||||||
new WorkOrder { AccountId = 1, LocationId = 2 },
|
new WorkOrder { AccountId = 1, LocationId = 2 },
|
||||||
new WorkOrder { AccountId = 1, LocationId = 3 },
|
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 });
|
new WorkOrder { AccountId = 1, LocationId = null });
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
|
@ -371,7 +379,28 @@ public class DashboardServiceTests
|
||||||
|
|
||||||
result.Items.Select(row => row.Region).Should().Equal(
|
result.Items.Select(row => row.Region).Should().Equal(
|
||||||
"East", "Central", "West", "California", "Unmapped/Other");
|
"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]
|
[Fact]
|
||||||
|
|
@ -417,6 +446,275 @@ public class DashboardServiceTests
|
||||||
result.Items[0].RescheduleRate.Should().Be(50);
|
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]
|
[Fact]
|
||||||
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal file
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal file
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
dueCount = stats.DueCount,
|
dueCount = stats.DueCount,
|
||||||
completedDueCount = stats.CompletedDueCount,
|
completedDueCount = stats.CompletedDueCount,
|
||||||
completionRate = stats.CompletionRate,
|
completionRate = stats.CompletionRate,
|
||||||
averageResolutionDays = stats.AverageResolutionDays
|
averageResolutionDays = stats.AverageResolutionDays,
|
||||||
|
statusDistribution = stats.StatusDistribution
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal file
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries
|
||||||
.HasIndex(w => w.InternalWONumber)
|
.HasIndex(w => w.InternalWONumber)
|
||||||
.HasFilter("[istemplate] = 0");
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
|
builder.Entity<WorkOrder>()
|
||||||
|
.HasOne(w => w.ServiceDefinition)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(w => w.ServiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
builder.Entity<WorkOrder>()
|
builder.Entity<WorkOrder>()
|
||||||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||||
.HasFilter("[istemplate] = 0");
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
@ -207,6 +213,53 @@ namespace Data.SeaHavenIndustries
|
||||||
.HasIndex(t => t.NormalizedName)
|
.HasIndex(t => t.NormalizedName)
|
||||||
.IsUnique();
|
.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.
|
// SH-278 Area catalogue: bounded names so the unique key is indexable.
|
||||||
builder.Entity<Area>()
|
builder.Entity<Area>()
|
||||||
.Property(a => a.Name)
|
.Property(a => a.Name)
|
||||||
|
|
@ -287,6 +340,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
||||||
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
|
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
|
||||||
public DbSet<TaskListTemplateItem> TaskListTemplateItems { 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
|
// New DbSets for added entities
|
||||||
public DbSet<Accounts> Accounts { get; set; }
|
public DbSet<Accounts> Accounts { get; set; }
|
||||||
|
|
|
||||||
4074
Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs
generated
Normal file
4074
Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1791,6 +1791,121 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("Regions");
|
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 =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -2648,6 +2763,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("nvarchar(128)");
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<int?>("ServiceId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceNameSnapshot")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<string>("ServiceNotes")
|
b.Property<string>("ServiceNotes")
|
||||||
.HasMaxLength(4000)
|
.HasMaxLength(4000)
|
||||||
.HasColumnType("nvarchar(4000)");
|
.HasColumnType("nvarchar(4000)");
|
||||||
|
|
@ -2732,6 +2854,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
|
b.HasIndex("ServiceId");
|
||||||
|
|
||||||
b.HasIndex("SiteCode")
|
b.HasIndex("SiteCode")
|
||||||
.HasFilter("[istemplate] = 0");
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
|
|
@ -3577,6 +3701,27 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Quotes");
|
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 =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
|
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
|
||||||
|
|
@ -3740,6 +3885,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasForeignKey("PrimaryDispatchId")
|
.HasForeignKey("PrimaryDispatchId")
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ServiceId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
|
|
||||||
b.Navigation("AssignToUser");
|
b.Navigation("AssignToUser");
|
||||||
|
|
@ -3747,6 +3897,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Locations");
|
b.Navigation("Locations");
|
||||||
|
|
||||||
b.Navigation("PrimaryDispatch");
|
b.Navigation("PrimaryDispatch");
|
||||||
|
|
||||||
|
b.Navigation("ServiceDefinition");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||||
|
|
@ -3966,6 +4118,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Employees");
|
b.Navigation("Employees");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("SupportedWorkOrderTypes");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
|
|
|
||||||
29
Data.SeaHavenIndustries/Models/Service.cs
Normal file
29
Data.SeaHavenIndustries/Models/Service.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,11 @@ namespace Data.SeaHavenIndustries
|
||||||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string? Service { get; set; }
|
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; }
|
public int? PrimaryDispatchId { get; set; }
|
||||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.OriginalDate,
|
w.OriginalDate,
|
||||||
w.OriginalWeek,
|
w.OriginalWeek,
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
|
w.ServiceId,
|
||||||
|
w.ServiceNameSnapshot,
|
||||||
w.SiteCode,
|
w.SiteCode,
|
||||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||||
WoPocName = w.PocName,
|
WoPocName = w.PocName,
|
||||||
|
|
@ -136,6 +138,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.CarriedOver,
|
w.CarriedOver,
|
||||||
w.IsAddOn,
|
w.IsAddOn,
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
|
w.ServiceId,
|
||||||
|
w.ServiceNameSnapshot,
|
||||||
frozenSite?.Code ?? w.SiteCode,
|
frozenSite?.Code ?? w.SiteCode,
|
||||||
frozenSite?.Label ?? w.LocationName,
|
frozenSite?.Label ?? w.LocationName,
|
||||||
pocName,
|
pocName,
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,6 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
on userRole.RoleId equals role.Id
|
on userRole.RoleId equals role.Id
|
||||||
where user.IsDeleted != true
|
where user.IsDeleted != true
|
||||||
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
|
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
|
||||||
&& (dispatcherId == null || user.Id == dispatcherId)
|
|
||||||
select new
|
select new
|
||||||
{
|
{
|
||||||
user.Id,
|
user.Id,
|
||||||
|
|
@ -178,6 +177,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
if (accountId is int scopedAccountId)
|
if (accountId is int scopedAccountId)
|
||||||
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, 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)
|
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
||||||
{
|
{
|
||||||
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int CarriedOver,
|
int CarriedOver,
|
||||||
bool IsAddOn,
|
bool IsAddOn,
|
||||||
WorkOrderType? WorkOrderType,
|
WorkOrderType? WorkOrderType,
|
||||||
|
int? ServiceId,
|
||||||
|
string? ServiceNameSnapshot,
|
||||||
string? SiteCode,
|
string? SiteCode,
|
||||||
string? LocationName,
|
string? LocationName,
|
||||||
string? PocName,
|
string? PocName,
|
||||||
|
|
|
||||||
14
SeaHaven.DataServices/Models/ServicesRegistryModels.cs
Normal file
14
SeaHaven.DataServices/Models/ServicesRegistryModels.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public DateOnly? DateFrom { get; set; }
|
public DateOnly? DateFrom { get; set; }
|
||||||
public DateOnly? DateTo { get; set; }
|
public DateOnly? DateTo { get; set; }
|
||||||
|
public string? DispatcherId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
||||||
|
|
@ -63,6 +64,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Color { get; init; }
|
public string? Color { get; init; }
|
||||||
public decimal CompletionRate { get; init; }
|
public decimal CompletionRate { get; init; }
|
||||||
public decimal? AverageResolutionDays { get; init; }
|
public decimal? AverageResolutionDays { get; init; }
|
||||||
|
public int AssignedCount { get; init; }
|
||||||
|
public int CompletedCount { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardRegionResponseDTO
|
public sealed class DashboardRegionResponseDTO
|
||||||
|
|
@ -108,6 +111,13 @@ namespace SeaHaven.Services.DTOs
|
||||||
public int CompletedDueCount { get; set; }
|
public int CompletedDueCount { get; set; }
|
||||||
public decimal CompletionRate { get; set; }
|
public decimal CompletionRate { get; set; }
|
||||||
public decimal? AverageResolutionDays { 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
|
public sealed class DashboardBreakdownDTO
|
||||||
|
|
|
||||||
39
SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs
Normal file
39
SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs
Normal 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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public DateOnly? OriginalDate { get; set; }
|
public DateOnly? OriginalDate { get; set; }
|
||||||
public DateOnly? OriginalWeek { get; set; }
|
public DateOnly? OriginalWeek { get; set; }
|
||||||
public WorkOrderType? WorkOrderType { get; set; }
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
|
public int? ServiceId { get; set; }
|
||||||
|
public string? ServiceNameSnapshot { get; set; }
|
||||||
public bool IsPastDue { get; set; }
|
public bool IsPastDue { get; set; }
|
||||||
public string? SiteCode { get; set; }
|
public string? SiteCode { get; set; }
|
||||||
public string? LocationName { get; set; }
|
public string? LocationName { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Trade { get; set; }
|
public string? Trade { get; set; }
|
||||||
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
|
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
|
||||||
public string? PrimaryService { get; set; }
|
public string? PrimaryService { get; set; }
|
||||||
|
public int? ServiceId { get; set; }
|
||||||
public List<string>? ExtraServices { get; set; }
|
public List<string>? ExtraServices { get; set; }
|
||||||
public string? ServiceNotes { get; set; }
|
public string? ServiceNotes { get; set; }
|
||||||
/// <summary>Per-WO Aveta compliance. Omit or false = not required.</summary>
|
/// <summary>Per-WO Aveta compliance. Omit or false = not required.</summary>
|
||||||
|
|
|
||||||
12
SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs
Normal file
12
SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,9 +61,26 @@ public static class DashboardMetrics
|
||||||
dueCount,
|
dueCount,
|
||||||
completedDueCount,
|
completedDueCount,
|
||||||
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
|
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(
|
private static bool IsPastDue(
|
||||||
DashboardWorkOrder workOrder,
|
DashboardWorkOrder workOrder,
|
||||||
LifecycleStatus? status,
|
LifecycleStatus? status,
|
||||||
|
|
@ -110,4 +127,5 @@ public sealed record DashboardMetricResult(
|
||||||
int DueCount,
|
int DueCount,
|
||||||
int CompletedDueCount,
|
int CompletedDueCount,
|
||||||
decimal CompletionRate,
|
decimal CompletionRate,
|
||||||
decimal? AverageResolutionDays);
|
decimal? AverageResolutionDays,
|
||||||
|
IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ public static class DashboardRegions
|
||||||
|
|
||||||
public static string Resolve(string? state)
|
public static string Resolve(string? state)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(state))
|
var code = UsStateCodes.ToCode(state);
|
||||||
|
if (code is null)
|
||||||
return "Unmapped/Other";
|
return "Unmapped/Other";
|
||||||
|
|
||||||
return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region)
|
return StateToRegion.TryGetValue(code, out var region)
|
||||||
? region
|
? region
|
||||||
: "Unmapped/Other";
|
: "Unmapped/Other";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,29 @@ namespace SeaHaven.Services.Helpers
|
||||||
["WY"] = "WYOMING"
|
["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);
|
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)
|
public static IReadOnlyCollection<string> ExpandStorageValues(IEnumerable<string> codes)
|
||||||
{
|
{
|
||||||
return codes
|
return codes
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
public const string DocStatus = "docStatus";
|
public const string DocStatus = "docStatus";
|
||||||
public const string CompletedDate = "completedDate";
|
public const string CompletedDate = "completedDate";
|
||||||
public const string Pm = "pm";
|
public const string Pm = "pm";
|
||||||
|
public const string ServiceId = "serviceId";
|
||||||
public const string ServiceNotes = "serviceNotes";
|
public const string ServiceNotes = "serviceNotes";
|
||||||
public const string ExtraServices = "extraServices";
|
public const string ExtraServices = "extraServices";
|
||||||
public const string AdditionalContacts = "additionalContacts";
|
public const string AdditionalContacts = "additionalContacts";
|
||||||
|
|
@ -49,6 +50,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
DocStatus,
|
DocStatus,
|
||||||
CompletedDate,
|
CompletedDate,
|
||||||
Pm,
|
Pm,
|
||||||
|
ServiceId,
|
||||||
ServiceNotes,
|
ServiceNotes,
|
||||||
ExtraServices,
|
ExtraServices,
|
||||||
AdditionalContacts,
|
AdditionalContacts,
|
||||||
|
|
@ -93,6 +95,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
DocStatus => "DocStatus",
|
DocStatus => "DocStatus",
|
||||||
CompletedDate => "CompletedDate",
|
CompletedDate => "CompletedDate",
|
||||||
Pm => "Trade",
|
Pm => "Trade",
|
||||||
|
ServiceId => "ServiceId",
|
||||||
ServiceNotes => "ServiceNotes",
|
ServiceNotes => "ServiceNotes",
|
||||||
ExtraServices => "ExtraServices",
|
ExtraServices => "ExtraServices",
|
||||||
AdditionalContacts => "AdditionalContacts",
|
AdditionalContacts => "AdditionalContacts",
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
||||||
: null;
|
|
||||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
||||||
|
|
||||||
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
||||||
var kpis = await _dataService.GetKpiCountsAsync(
|
var kpis = await _dataService.GetKpiCountsAsync(
|
||||||
|
|
@ -63,7 +59,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
DueCount = metrics.DueCount,
|
DueCount = metrics.DueCount,
|
||||||
CompletedDueCount = metrics.CompletedDueCount,
|
CompletedDueCount = metrics.CompletedDueCount,
|
||||||
CompletionRate = metrics.CompletionRate,
|
CompletionRate = metrics.CompletionRate,
|
||||||
AverageResolutionDays = metrics.AverageResolutionDays
|
AverageResolutionDays = metrics.AverageResolutionDays,
|
||||||
|
StatusDistribution = metrics.StatusDistribution
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,11 +74,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new ArgumentOutOfRangeException(nameof(page));
|
throw new ArgumentOutOfRangeException(nameof(page));
|
||||||
|
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
||||||
: null;
|
|
||||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
||||||
|
|
||||||
var rows = await _dataService.GetDispatcherWorkloadAsync(
|
var rows = await _dataService.GetDispatcherWorkloadAsync(
|
||||||
accountId,
|
accountId,
|
||||||
|
|
@ -123,11 +116,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new ArgumentOutOfRangeException(nameof(page));
|
throw new ArgumentOutOfRangeException(nameof(page));
|
||||||
|
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
||||||
: null;
|
|
||||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
||||||
|
|
||||||
var directory = await _dataService.GetDispatcherWorkloadAsync(
|
var directory = await _dataService.GetDispatcherWorkloadAsync(
|
||||||
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
||||||
|
|
@ -145,7 +134,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
DispatcherName = dispatcher.DispatcherName,
|
DispatcherName = dispatcher.DispatcherName,
|
||||||
Color = dispatcher.Color,
|
Color = dispatcher.Color,
|
||||||
CompletionRate = metrics.CompletionRate,
|
CompletionRate = metrics.CompletionRate,
|
||||||
AverageResolutionDays = metrics.AverageResolutionDays
|
AverageResolutionDays = metrics.AverageResolutionDays,
|
||||||
|
AssignedCount = metrics.Total,
|
||||||
|
CompletedCount = metrics.Completed
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderByDescending(row => row.CompletionRate)
|
.OrderByDescending(row => row.CompletionRate)
|
||||||
|
|
@ -169,11 +160,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
||||||
: null;
|
|
||||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
||||||
|
|
||||||
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||||
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
||||||
|
|
@ -196,11 +183,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var dispatcherId = user.IsInRole("Dispatcher")
|
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
||||||
: null;
|
|
||||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
||||||
|
|
||||||
var today = DashboardBusinessTime.Today();
|
var today = DashboardBusinessTime.Today();
|
||||||
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
|
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
|
||||||
|
|
@ -404,5 +387,33 @@ namespace SeaHaven.Services.Implementation
|
||||||
TotalVendors = rows.Count
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
255
SeaHaven.Services/Implementation/ServicesRegistryService.cs
Normal file
255
SeaHaven.Services/Implementation/ServicesRegistryService.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
private readonly IWorkOrderBoardCreateValidation _validator;
|
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||||
|
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||||
|
|
||||||
public WorkOrderBoardCreateService(
|
public WorkOrderBoardCreateService(
|
||||||
IWorkOrderBoardDataService boardDataService,
|
IWorkOrderBoardDataService boardDataService,
|
||||||
|
|
@ -27,7 +28,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
IWorkOrderBoardService boardService,
|
IWorkOrderBoardService boardService,
|
||||||
IWorkOrderAuditService auditService,
|
IWorkOrderAuditService auditService,
|
||||||
IWorkOrderBoardCreateValidation validator,
|
IWorkOrderBoardCreateValidation validator,
|
||||||
IWorkOrderAccountResolver accountResolver)
|
IWorkOrderAccountResolver accountResolver,
|
||||||
|
IServicesRegistryService? servicesRegistryService = null)
|
||||||
{
|
{
|
||||||
_boardDataService = boardDataService;
|
_boardDataService = boardDataService;
|
||||||
_mutationData = mutationData;
|
_mutationData = mutationData;
|
||||||
|
|
@ -35,6 +37,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
_validator = validator;
|
_validator = validator;
|
||||||
_accountResolver = accountResolver;
|
_accountResolver = accountResolver;
|
||||||
|
_servicesRegistryService = servicesRegistryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
||||||
|
|
@ -54,7 +57,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
||||||
var siteCode = request.SiteCode!.Trim();
|
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 extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||||
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
||||||
var pocName = TrimOrNull(request.PocName);
|
var pocName = TrimOrNull(request.PocName);
|
||||||
|
|
@ -87,6 +91,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
AccountId = accountId,
|
AccountId = accountId,
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
Trade = primaryService,
|
Trade = primaryService,
|
||||||
|
ServiceId = selectedService?.Id,
|
||||||
|
ServiceNameSnapshot = selectedService?.Name,
|
||||||
ExtraServices = extraServicesJson,
|
ExtraServices = extraServicesJson,
|
||||||
ServiceNotes = serviceNotes,
|
ServiceNotes = serviceNotes,
|
||||||
AvetaRequired = request.AvetaRequired == true,
|
AvetaRequired = request.AvetaRequired == true,
|
||||||
|
|
@ -138,6 +144,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||||
if (workOrder.Trade != null)
|
if (workOrder.Trade != null)
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
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)
|
if (workOrder.ExtraServices != null)
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
||||||
if (workOrder.ServiceNotes != null)
|
if (workOrder.ServiceNotes != null)
|
||||||
|
|
@ -251,6 +259,46 @@ namespace SeaHaven.Services.Implementation
|
||||||
return null;
|
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)
|
private static string? SerializeExtraServices(List<string>? extras)
|
||||||
{
|
{
|
||||||
if (extras == null || extras.Count == 0)
|
if (extras == null || extras.Count == 0)
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
OriginalDate = row.OriginalDate,
|
OriginalDate = row.OriginalDate,
|
||||||
OriginalWeek = row.OriginalWeek,
|
OriginalWeek = row.OriginalWeek,
|
||||||
WorkOrderType = row.WorkOrderType,
|
WorkOrderType = row.WorkOrderType,
|
||||||
|
ServiceId = row.ServiceId,
|
||||||
|
ServiceNameSnapshot = row.ServiceNameSnapshot,
|
||||||
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
||||||
SiteCode = row.SiteCode,
|
SiteCode = row.SiteCode,
|
||||||
LocationName = row.LocationName,
|
LocationName = row.LocationName,
|
||||||
|
|
@ -136,7 +138,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
TechPhone = row.TechPhone,
|
TechPhone = row.TechPhone,
|
||||||
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
||||||
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
||||||
Pm = row.Trade ?? row.Problem,
|
Pm = row.Trade ?? row.ServiceNameSnapshot ?? row.Problem,
|
||||||
ServiceNotes = row.ServiceNotes,
|
ServiceNotes = row.ServiceNotes,
|
||||||
ExtraServices = ParseExtraServices(row.ExtraServices),
|
ExtraServices = ParseExtraServices(row.ExtraServices),
|
||||||
AdditionalContacts = MapAdditionalContacts(row),
|
AdditionalContacts = MapAdditionalContacts(row),
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderBoardDataService _boardDataService;
|
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||||
|
|
||||||
public WorkOrderBoardUpdateService(
|
public WorkOrderBoardUpdateService(
|
||||||
IWorkOrderBoardDataService boardDataService,
|
IWorkOrderBoardDataService boardDataService,
|
||||||
IWorkOrderBoardMutationDataService mutationData,
|
IWorkOrderBoardMutationDataService mutationData,
|
||||||
IWorkOrderAuditService auditService)
|
IWorkOrderAuditService auditService,
|
||||||
|
IServicesRegistryService? servicesRegistryService = null)
|
||||||
{
|
{
|
||||||
_boardDataService = boardDataService;
|
_boardDataService = boardDataService;
|
||||||
_mutationData = mutationData;
|
_mutationData = mutationData;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
|
_servicesRegistryService = servicesRegistryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
|
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
|
||||||
|
|
@ -73,6 +76,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
_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;
|
Dispatch? dispatch = null;
|
||||||
ResolvedDispatch? resolved = null;
|
ResolvedDispatch? resolved = null;
|
||||||
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
|
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
|
||||||
|
|
@ -181,6 +191,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
||||||
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
|
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
|
||||||
|
WorkOrderBoardFieldNames.ServiceId => await ApplyServiceIdAsync(workOrder, value, auditField, cancellationToken),
|
||||||
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||||
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
|
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
|
||||||
|
|
@ -388,6 +399,83 @@ namespace SeaHaven.Services.Implementation
|
||||||
return changes;
|
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)
|
private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField)
|
||||||
{
|
{
|
||||||
if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized))
|
if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized))
|
||||||
|
|
|
||||||
19
SeaHaven.Services/Interfaces/IServicesRegistryService.cs
Normal file
19
SeaHaven.Services/Interfaces/IServicesRegistryService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
342
SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs
Normal file
342
SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1043,6 +1043,24 @@ public class WorkOrderBoardServiceTests
|
||||||
Assert.Equal("09:00", dto.ApptTime);
|
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(
|
private static WorkOrderBoardRawRow RawRow(
|
||||||
DateTime? scheduledDate,
|
DateTime? scheduledDate,
|
||||||
DateTime? scheduledStart,
|
DateTime? scheduledStart,
|
||||||
|
|
@ -1055,6 +1073,8 @@ public class WorkOrderBoardServiceTests
|
||||||
CarriedOver: 0,
|
CarriedOver: 0,
|
||||||
IsAddOn: false,
|
IsAddOn: false,
|
||||||
WorkOrderType: null,
|
WorkOrderType: null,
|
||||||
|
ServiceId: null,
|
||||||
|
ServiceNameSnapshot: null,
|
||||||
SiteCode: null,
|
SiteCode: null,
|
||||||
LocationName: null,
|
LocationName: null,
|
||||||
PocName: null,
|
PocName: null,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue