mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California) with stable ids, add a nullable AreaId to VendorCompany, allow only Admins to change it through the roster endpoints, expose areas facet metadata and an areas[n] company-directory filter with the __unassigned__ sentinel.
This commit is contained in:
parent
d07ac42452
commit
8515676f4d
27 changed files with 4963 additions and 60 deletions
|
|
@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests
|
||||||
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_Returns200()
|
public async Task Create_Returns200()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(SampleRoster());
|
.ReturnsAsync(SampleRoster());
|
||||||
|
|
||||||
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Create_ValidationException_Returns400()
|
public async Task Create_ValidationException_Returns400()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
|
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
|
||||||
|
|
||||||
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
|
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
|
||||||
|
|
@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Create_DuplicateName_ReturnsStable409()
|
public async Task Create_DuplicateName_ReturnsStable409()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new VendorRosterDuplicateNameException(
|
.ThrowsAsync(new VendorRosterDuplicateNameException(
|
||||||
"database detail",
|
"database detail",
|
||||||
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
|
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
|
||||||
|
|
@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests
|
||||||
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reconcile_ValidationException_Returns400()
|
public async Task Reconcile_ValidationException_Returns400()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
|
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Reconcile_CompanyNotFound_Returns404()
|
public async Task Reconcile_CompanyNotFound_Returns404()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new KeyNotFoundException("not found"));
|
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
|
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
|
||||||
};
|
};
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
|
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests
|
||||||
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Patch_AddsTechnicians_Returns200WithRoster()
|
public async Task Patch_AddsTechnicians_Returns200WithRoster()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(SampleRoster());
|
.ReturnsAsync(SampleRoster());
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Patch_ValidationException_Returns400()
|
public async Task Patch_ValidationException_Returns400()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
|
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Patch_CompanyNotFound_Returns404()
|
public async Task Patch_CompanyNotFound_Returns404()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new KeyNotFoundException("not found"));
|
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests
|
||||||
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||||
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
|
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AreaAssignmentForbidden_Returns403OnEveryMutation()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||||
|
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||||
|
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var results = new[]
|
||||||
|
{
|
||||||
|
await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None),
|
||||||
|
await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None),
|
||||||
|
await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var result in results)
|
||||||
|
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
|
||||||
|
|
||||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
context.Areas.AddRange(
|
||||||
|
new Area { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new Area { Id = 2, Name = "Central", NormalizedName = "central" });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech");
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new()
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Area Co",
|
||||||
|
Email = "area@example.com",
|
||||||
|
AreaIdProvided = provided,
|
||||||
|
AreaId = areaId,
|
||||||
|
Technicians = context.Vendors.Where(v => v.CompanyId == companyId)
|
||||||
|
.Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName })
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None);
|
||||||
|
assigned.AreaId.Should().Be(2);
|
||||||
|
assigned.AreaName.Should().Be("Central");
|
||||||
|
|
||||||
|
var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None);
|
||||||
|
untouched.AreaId.Should().Be(2);
|
||||||
|
|
||||||
|
var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None);
|
||||||
|
cleared.AreaId.Should().BeNull();
|
||||||
|
cleared.AreaName.Should().BeNull();
|
||||||
|
|
||||||
|
(await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue();
|
||||||
|
(await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using FluentAssertions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using System.Security.Claims;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public class VendorCompanyRosterServiceTests
|
public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
|
private static readonly ClaimsPrincipal Dispatcher = Principal("User");
|
||||||
|
private static readonly ClaimsPrincipal Admin = Principal("Admin");
|
||||||
|
|
||||||
|
private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) },
|
||||||
|
"Test"));
|
||||||
|
|
||||||
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
|
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
|
||||||
|
|
||||||
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
|
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
|
||||||
|
|
@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = new CreateVendorRosterDTO { Name = "Acme" };
|
var dto = new CreateVendorRosterDTO { Name = "Acme" };
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
|
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
|
||||||
|
|
@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Technicians = new List<RosterTechnicianInputDTO>()
|
Technicians = new List<RosterTechnicianInputDTO>()
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured.Should().NotBeNull();
|
captured.Should().NotBeNull();
|
||||||
captured!.Technicians.Should().BeEmpty();
|
captured!.Technicians.Should().BeEmpty();
|
||||||
|
|
@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Notes = notes
|
Notes = notes
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Notes.Should().Be(notes);
|
captured!.Notes.Should().Be(notes);
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Notes = new string('n', 501)
|
Notes = new string('n', 501)
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().ContainSingle(error =>
|
.Which.Errors.Should().ContainSingle(error =>
|
||||||
|
|
@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
GoogleMapsUrl = "http://maps.google.com/acme"
|
GoogleMapsUrl = "http://maps.google.com/acme"
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
|
||||||
|
|
@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
|
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
|
||||||
}
|
}
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
|
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||||
|
|
@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme Co",
|
Name = "Acme Co",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured.Should().NotBeNull();
|
captured.Should().NotBeNull();
|
||||||
captured!.CompanyId.Should().Be(7);
|
captured!.CompanyId.Should().Be(7);
|
||||||
|
|
@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Notes = notes
|
Notes = notes
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Notes.Should().Be(notes);
|
captured!.Notes.Should().Be(notes);
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Notes = new string('n', 501)
|
Notes = new string('n', 501)
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().ContainSingle(error =>
|
.Which.Errors.Should().ContainSingle(error =>
|
||||||
|
|
@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Email = "acme@example.com"
|
Email = "acme@example.com"
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||||
|
|
@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Email = "acme@example.com"
|
Email = "acme@example.com"
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||||
|
|
@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Technicians = null!
|
Technicians = null!
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Technicians.Should().BeEmpty();
|
captured!.Technicians.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
|
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
|
||||||
}
|
}
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Technicians.Should().ContainSingle();
|
captured!.Technicians.Should().ContainSingle();
|
||||||
captured.Technicians.Single().ContactName.Should().BeEmpty();
|
captured.Technicians.Single().ContactName.Should().BeEmpty();
|
||||||
|
|
@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
}, "42", cts.Token);
|
}, "42", Dispatcher, cts.Token);
|
||||||
|
|
||||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
|
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
|
||||||
{
|
{
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None);
|
||||||
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
|
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
|
||||||
|
|
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
|
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
|
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
|
||||||
|
|
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
|
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||||
|
|
@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured.Should().NotBeNull();
|
captured.Should().NotBeNull();
|
||||||
captured!.CompanyId.Should().Be(7);
|
captured!.CompanyId.Should().Be(7);
|
||||||
|
|
@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
RowVersion = "AAAAAAAAD8I=",
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.CompanyFields!.Notes.Should().Be(notes);
|
captured!.CompanyFields!.Notes.Should().Be(notes);
|
||||||
}
|
}
|
||||||
|
|
@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().ContainSingle(error =>
|
.Which.Errors.Should().ContainSingle(error =>
|
||||||
|
|
@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
|
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
|
||||||
// non-null write model made the data layer bump RowVersion and rewrite every
|
// non-null write model made the data layer bump RowVersion and rewrite every
|
||||||
|
|
@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(
|
data.Verify(
|
||||||
|
|
@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.CompanyFields.Should().NotBeNull();
|
captured!.CompanyFields.Should().NotBeNull();
|
||||||
captured.CompanyFields!.City.Should().Be("Norfolk");
|
captured.CompanyFields!.City.Should().Be("Norfolk");
|
||||||
|
|
@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests
|
||||||
.ReturnsAsync(SampleReadModel(7, 1));
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
|
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token);
|
||||||
|
|
||||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId)
|
||||||
|
{
|
||||||
|
var model = SampleReadModel(7, 1);
|
||||||
|
model.AreaId = areaId;
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 };
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||||
|
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 };
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(ReadModelWithArea(2));
|
||||||
|
|
||||||
|
var result = await NewService(data).CreateRosterAsync(
|
||||||
|
new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 },
|
||||||
|
"42",
|
||||||
|
Admin,
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
|
captured!.AreaId.Should().Be(2);
|
||||||
|
result.AreaId.Should().Be(2);
|
||||||
|
data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
|
||||||
|
foreach (int? requested in new int?[] { 3, null })
|
||||||
|
{
|
||||||
|
var dto = new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
AreaId = requested
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
var captured = new List<VendorCompanyRosterWriteModel>();
|
||||||
|
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured.Add(model))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme Renamed",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
AreaId = 1
|
||||||
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme Renamed",
|
||||||
|
Email = "acme@example.com"
|
||||||
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Should().HaveCount(2);
|
||||||
|
captured[0].AreaIdProvided.Should().BeTrue();
|
||||||
|
captured[0].AreaId.Should().Be(1);
|
||||||
|
captured[1].AreaIdProvided.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_AdminClearsAreaToUnassigned()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(ReadModelWithArea(null));
|
||||||
|
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
AreaId = null
|
||||||
|
}, "42", Admin, CancellationToken.None);
|
||||||
|
|
||||||
|
captured!.AreaIdProvided.Should().BeTrue();
|
||||||
|
captured.AreaId.Should().BeNull();
|
||||||
|
data.Verify(x => x.IsActiveAreaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(null));
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
|
||||||
|
{
|
||||||
|
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||||
|
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
|
||||||
|
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||||
|
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||||
|
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
|
||||||
|
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||||
|
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||||
|
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""",
|
||||||
|
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||||
|
|
||||||
|
omitted.AreaIdProvided.Should().BeFalse();
|
||||||
|
cleared.AreaIdProvided.Should().BeTrue();
|
||||||
|
cleared.AreaId.Should().BeNull();
|
||||||
|
assigned.AreaIdProvided.Should().BeTrue();
|
||||||
|
assigned.AreaId.Should().Be(3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ public class VendorControllerTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<CancellationToken>()), Times.Never);
|
It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,6 +156,7 @@ public class VendorControllerTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
token))
|
token))
|
||||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||||
{
|
{
|
||||||
|
|
@ -203,6 +205,7 @@ public class VendorControllerTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
token), Times.Once);
|
token), Times.Once);
|
||||||
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
It.IsAny<int>(),
|
It.IsAny<int>(),
|
||||||
|
|
|
||||||
|
|
@ -470,12 +470,14 @@ public class VendorDataServiceTests
|
||||||
search: "alice",
|
search: "alice",
|
||||||
trades: new[] { "Plumbing" },
|
trades: new[] { "Plumbing" },
|
||||||
locations: new[] { "Chicago, IL" },
|
locations: new[] { "Chicago, IL" },
|
||||||
jobBuckets: new[] { "under-50" }).ToQueryString();
|
jobBuckets: new[] { "under-50" },
|
||||||
|
areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString();
|
||||||
|
|
||||||
sql.Should().Contain("GROUP BY");
|
sql.Should().Contain("GROUP BY");
|
||||||
sql.Should().Contain("LEFT JOIN");
|
sql.Should().Contain("LEFT JOIN");
|
||||||
sql.Should().Contain("TradeSpecialties");
|
sql.Should().Contain("TradeSpecialties");
|
||||||
sql.Should().Contain("TotalJobs");
|
sql.Should().Contain("TotalJobs");
|
||||||
|
sql.Should().Contain("AreaId");
|
||||||
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
||||||
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
||||||
}
|
}
|
||||||
|
|
@ -1044,4 +1046,69 @@ public class VendorDataServiceTests
|
||||||
sql.Should().Contain("SELECT");
|
sql.Should().Contain("SELECT");
|
||||||
sql.Should().Contain("VendorCompanies");
|
sql.Should().Contain("VendorCompanies");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var east = new Area { Id = 1, Name = "East", NormalizedName = "east" };
|
||||||
|
var west = new Area { Id = 3, Name = "West", NormalizedName = "west" };
|
||||||
|
context.Areas.AddRange(east, west);
|
||||||
|
var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east };
|
||||||
|
var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west };
|
||||||
|
var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" };
|
||||||
|
var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east };
|
||||||
|
context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly);
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var eastResult = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
|
||||||
|
eastResult.Items.Select(item => item.CompanyName)
|
||||||
|
.Should().BeEquivalentTo("East Co", "East Only");
|
||||||
|
eastResult.TotalCount.Should().Be(2);
|
||||||
|
var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co");
|
||||||
|
eastRow.AreaId.Should().Be(east.Id);
|
||||||
|
eastRow.AreaName.Should().Be("East");
|
||||||
|
eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East");
|
||||||
|
|
||||||
|
var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true,
|
||||||
|
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true });
|
||||||
|
eastOrUnassigned.Items.Select(item => item.CompanyName)
|
||||||
|
.Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co");
|
||||||
|
eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull();
|
||||||
|
|
||||||
|
var eastAndBoston = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, locations: new[] { "Boston, MA" },
|
||||||
|
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
|
||||||
|
eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co");
|
||||||
|
|
||||||
|
var matchesNobody = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, areaFilter: new VendorAreaFilter());
|
||||||
|
matchesNobody.TotalCount.Should().Be(0);
|
||||||
|
matchesNobody.Items.Should().BeEmpty();
|
||||||
|
|
||||||
|
var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
|
||||||
|
unfiltered.TotalCount.Should().Be(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAreasAsync_ExcludesArchivedAreas()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
context.Areas.AddRange(
|
||||||
|
new Area { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
areas.Select(area => area.Name).Should().Equal("East");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
token))
|
token))
|
||||||
.ReturnsAsync((new[]
|
.ReturnsAsync((new[]
|
||||||
{
|
{
|
||||||
|
|
@ -212,6 +213,7 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
token), Times.Once);
|
token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +230,7 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||||
|
|
||||||
|
|
@ -756,6 +759,8 @@ public class VendorServiceTests
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Trade>());
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -794,6 +799,8 @@ public class VendorServiceTests
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Trade>());
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -822,6 +829,8 @@ public class VendorServiceTests
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Trade>());
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -849,6 +858,8 @@ public class VendorServiceTests
|
||||||
SortOrder = index + 1
|
SortOrder = index + 1
|
||||||
})
|
})
|
||||||
.ToList());
|
.ToList());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
|
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
|
||||||
|
|
||||||
|
|
@ -898,4 +909,96 @@ public class VendorServiceTests
|
||||||
TradeCatalog.CanonicalTrades,
|
TradeCatalog.CanonicalTrades,
|
||||||
opts => opts.WithStrictOrdering());
|
opts => opts.WithStrictOrdering());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new() { Id = 3, Name = "West", NormalizedName = "west" }
|
||||||
|
});
|
||||||
|
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<string?>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
|
||||||
|
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
|
||||||
|
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel()
|
||||||
|
{
|
||||||
|
VendorAreaFilter? captured = null;
|
||||||
|
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||||
|
|
||||||
|
await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" });
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.AreaIds.Should().Equal(1);
|
||||||
|
captured.IncludeUnassigned.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody()
|
||||||
|
{
|
||||||
|
VendorAreaFilter? captured = null;
|
||||||
|
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||||
|
|
||||||
|
// 4 is not in the active catalogue (unknown or archived); "West" is a label.
|
||||||
|
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
1, 10, areas: new[] { "4", "West", "-3" });
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.AreaIds.Should().BeEmpty();
|
||||||
|
captured.IncludeUnassigned.Should().BeFalse();
|
||||||
|
result.Items.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter()
|
||||||
|
{
|
||||||
|
VendorAreaFilter? captured = new();
|
||||||
|
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||||
|
|
||||||
|
await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" });
|
||||||
|
|
||||||
|
captured.Should().BeNull();
|
||||||
|
data.Verify(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<VendorCompany>());
|
||||||
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new() { Id = 3, Name = "West", NormalizedName = "west" },
|
||||||
|
new() { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new() { Id = 4, Name = "california", NormalizedName = "california" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var facets = await NewService(data).GetFacetsAsync(null, cts.Token);
|
||||||
|
|
||||||
|
facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal(
|
||||||
|
(4, "california"), (1, "East"), (3, "West"));
|
||||||
|
data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken);
|
var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken);
|
||||||
return Ok(roster);
|
return Ok(roster);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
catch (VendorAreaAssignmentForbiddenException)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||||
|
}
|
||||||
catch (VendorRosterDuplicateNameException dup)
|
catch (VendorRosterDuplicateNameException dup)
|
||||||
{
|
{
|
||||||
return Conflict(new
|
return Conflict(new
|
||||||
|
|
@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken);
|
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken);
|
||||||
return Ok(roster);
|
return Ok(roster);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
catch (VendorAreaAssignmentForbiddenException)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||||
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (KeyNotFoundException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||||
|
|
@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken);
|
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken);
|
||||||
return Ok(roster);
|
return Ok(roster);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
catch (VendorAreaAssignmentForbiddenException)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||||
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (KeyNotFoundException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[FromQuery] string[]? trades = null,
|
[FromQuery] string[]? trades = null,
|
||||||
[FromQuery] string[]? locations = null,
|
[FromQuery] string[]? locations = null,
|
||||||
[FromQuery] string[]? jobBuckets = null,
|
[FromQuery] string[]? jobBuckets = null,
|
||||||
|
[FromQuery] string[]? areas = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
|
@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
trades,
|
trades,
|
||||||
locations,
|
locations,
|
||||||
jobBuckets,
|
jobBuckets,
|
||||||
|
areas,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var data = pagedResult.Items.Select(v => new
|
var data = pagedResult.Items.Select(v => new
|
||||||
|
|
@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
v.Notes,
|
v.Notes,
|
||||||
v.IsActive,
|
v.IsActive,
|
||||||
v.TotalJobs,
|
v.TotalJobs,
|
||||||
|
v.AreaId,
|
||||||
|
v.AreaName,
|
||||||
Technicians = v.Technicians.Select(t => new
|
Technicians = v.Technicians.Select(t => new
|
||||||
{
|
{
|
||||||
t.Id,
|
t.Id,
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,25 @@ namespace Data.SeaHavenIndustries
|
||||||
builder.Entity<Trade>()
|
builder.Entity<Trade>()
|
||||||
.HasIndex(t => t.NormalizedName)
|
.HasIndex(t => t.NormalizedName)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
// SH-278 Area catalogue: bounded names so the unique key is indexable.
|
||||||
|
builder.Entity<Area>()
|
||||||
|
.Property(a => a.Name)
|
||||||
|
.HasMaxLength(128);
|
||||||
|
|
||||||
|
builder.Entity<Area>()
|
||||||
|
.Property(a => a.NormalizedName)
|
||||||
|
.HasMaxLength(128);
|
||||||
|
|
||||||
|
builder.Entity<Area>()
|
||||||
|
.HasIndex(a => a.NormalizedName)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
builder.Entity<VendorCompany>()
|
||||||
|
.HasOne(c => c.Area)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.AreaId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
}
|
}
|
||||||
public DbSet<Category> Categories { get; set; }
|
public DbSet<Category> Categories { get; set; }
|
||||||
public DbSet<Locations> Locations { get; set; }
|
public DbSet<Locations> Locations { get; set; }
|
||||||
|
|
@ -228,6 +247,7 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
||||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||||
public DbSet<Trade> Trades { get; set; }
|
public DbSet<Trade> Trades { get; set; }
|
||||||
|
public DbSet<Area> Areas { get; set; }
|
||||||
public DbSet<Vendor> Vendors { get; set; }
|
public DbSet<Vendor> Vendors { get; set; }
|
||||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||||
|
|
|
||||||
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,88 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SH278_VendorCompanyArea : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AreaId",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Areas",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
NormalizedName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Areas", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VendorCompanies_AreaId",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
column: "AreaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Areas_NormalizedName",
|
||||||
|
table: "Areas",
|
||||||
|
column: "NormalizedName",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
// SH-278 v1 seed of the four dispatcher territories confirmed by Product.
|
||||||
|
// Names match the merged SH-273 Sites mapping (East / Central / West /
|
||||||
|
// California). Ids are stable; later renames change only the display name.
|
||||||
|
// Existing vendor companies stay Unassigned (no backfill).
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Areas",
|
||||||
|
columns: new[] { "Id", "Name", "NormalizedName", "IsActive" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "East", "east", true },
|
||||||
|
{ 2, "Central", "central", true },
|
||||||
|
{ 3, "West", "west", true },
|
||||||
|
{ 4, "California", "california", true }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_VendorCompanies_Areas_AreaId",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
column: "AreaId",
|
||||||
|
principalTable: "Areas",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_VendorCompanies_Areas_AreaId",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Areas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_VendorCompanies_AreaId",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AreaId",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -286,6 +286,35 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.Area", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Areas");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -2237,6 +2266,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("Address")
|
b.Property<string>("Address")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("AreaId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("City")
|
b.Property<string>("City")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -2294,6 +2326,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AreaId");
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
b.HasIndex("NormalizedName")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|
@ -3614,6 +3648,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Vendor");
|
b.Navigation("Vendor");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.Area", "Area")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AreaId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Area");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
||||||
|
|
|
||||||
15
Data.SeaHavenIndustries/Models/Area.cs
Normal file
15
Data.SeaHavenIndustries/Models/Area.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
// SH-278: dispatcher territory catalogue for this Sea Haven deployment. Vendor
|
||||||
|
// companies carry zero or one Area by stable id; the display name is separate and
|
||||||
|
// may be renamed without changing assignments. Area is selected independently and
|
||||||
|
// is never derived from a company's State. Rows are organization-wide: vendor
|
||||||
|
// companies are not scoped to a customer Account.
|
||||||
|
public class Area
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string NormalizedName { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned.
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
[ForeignKey(nameof(AreaId))]
|
||||||
|
public Area? Area { get; set; }
|
||||||
|
|
||||||
[Timestamp]
|
[Timestamp]
|
||||||
public byte[]? RowVersion { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) =>
|
||||||
|
_context.Areas
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken);
|
||||||
|
|
||||||
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||||
int? vendorId,
|
int? vendorId,
|
||||||
int? companyId,
|
int? companyId,
|
||||||
|
|
@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Zip = c.Zip,
|
Zip = c.Zip,
|
||||||
GoogleMapsUrl = c.GoogleMapsUrl,
|
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||||
Notes = c.Notes,
|
Notes = c.Notes,
|
||||||
|
AreaId = c.AreaId,
|
||||||
|
AreaName = c.Area != null ? c.Area.Name : null,
|
||||||
RowVersion = c.RowVersion
|
RowVersion = c.RowVersion
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
company.Zip = roster.Zip;
|
company.Zip = roster.Zip;
|
||||||
company.GoogleMapsUrl = roster.GoogleMapsUrl;
|
company.GoogleMapsUrl = roster.GoogleMapsUrl;
|
||||||
company.Notes = roster.Notes;
|
company.Notes = roster.Notes;
|
||||||
|
if (roster.AreaIdProvided)
|
||||||
|
company.AreaId = roster.AreaId;
|
||||||
company.LastModificationTime = now;
|
company.LastModificationTime = now;
|
||||||
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
|
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
|
||||||
if (actorId.HasValue)
|
if (actorId.HasValue)
|
||||||
|
|
@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Zip = roster.Zip,
|
Zip = roster.Zip,
|
||||||
GoogleMapsUrl = roster.GoogleMapsUrl,
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
||||||
Notes = roster.Notes,
|
Notes = roster.Notes,
|
||||||
|
AreaId = roster.AreaId,
|
||||||
CreatedDate = now,
|
CreatedDate = now,
|
||||||
createdby = roster.ActorUserId
|
createdby = roster.ActorUserId
|
||||||
};
|
};
|
||||||
|
|
@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
company.GoogleMapsUrl = fields.GoogleMapsUrl;
|
company.GoogleMapsUrl = fields.GoogleMapsUrl;
|
||||||
if (fields.Notes != null)
|
if (fields.Notes != null)
|
||||||
company.Notes = fields.Notes;
|
company.Notes = fields.Notes;
|
||||||
|
if (fields.AreaIdProvided)
|
||||||
|
company.AreaId = fields.AreaId;
|
||||||
|
|
||||||
// Keep the denormalized company columns on existing technicians aligned
|
// Keep the denormalized company columns on existing technicians aligned
|
||||||
// with the company row (same invariant SaveRosterAsync maintains). This
|
// with the company row (same invariant SaveRosterAsync maintains). This
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return query.OrderBy(c => c.Id);
|
return query.OrderBy(c => c.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Areas
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.IsActive)
|
||||||
|
.OrderBy(a => a.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||||
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
|
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
VendorAreaFilter? areaFilter = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Phase 1: select and paginate bounded company group keys in SQL.
|
// Phase 1: select and paginate bounded company group keys in SQL.
|
||||||
|
|
@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
companies,
|
companies,
|
||||||
trades,
|
trades,
|
||||||
locations,
|
locations,
|
||||||
jobBuckets);
|
jobBuckets,
|
||||||
|
areaFilter);
|
||||||
|
|
||||||
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
? new Dictionary<int, VendorCompany>()
|
? new Dictionary<int, VendorCompany>()
|
||||||
: await _context.VendorCompanies
|
: await _context.VendorCompanies
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Area)
|
||||||
.Where(c => missingCompanyIds.Contains(c.Id))
|
.Where(c => missingCompanyIds.Contains(c.Id))
|
||||||
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
||||||
|
|
||||||
|
|
@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
||||||
GoogleMapsUrl = primary.GoogleMapsUrl,
|
GoogleMapsUrl = primary.GoogleMapsUrl,
|
||||||
Notes = linked ? primary.CompanyNotes : null,
|
Notes = linked ? primary.CompanyNotes : null,
|
||||||
|
AreaId = linked ? primary.CompanyAreaId : null,
|
||||||
|
AreaName = linked ? primary.CompanyAreaName : null,
|
||||||
IsActive = primary.IsActive,
|
IsActive = primary.IsActive,
|
||||||
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
||||||
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
||||||
|
|
@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = null,
|
TradeSpecialties = null,
|
||||||
GoogleMapsUrl = company.GoogleMapsUrl,
|
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||||
Notes = company.Notes,
|
Notes = company.Notes,
|
||||||
|
AreaId = company.AreaId,
|
||||||
|
AreaName = company.Area?.Name,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
TotalJobs = 0,
|
TotalJobs = 0,
|
||||||
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
||||||
|
|
@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
IReadOnlyCollection<string>? companies = null,
|
IReadOnlyCollection<string>? companies = null,
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null)
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
VendorAreaFilter? areaFilter = null)
|
||||||
{
|
{
|
||||||
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
||||||
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
|
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
|
||||||
|
|
||||||
var grouped = sourceRows
|
var grouped = sourceRows
|
||||||
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
||||||
|
|
@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
SearchMatch = group.Max(row => row.SearchFlag),
|
SearchMatch = group.Max(row => row.SearchFlag),
|
||||||
CompanyMatch = group.Max(row => row.CompanyFlag),
|
CompanyMatch = group.Max(row => row.CompanyFlag),
|
||||||
TradeMatch = group.Max(row => row.TradeFlag),
|
TradeMatch = group.Max(row => row.TradeFlag),
|
||||||
LocationMatch = group.Max(row => row.LocationFlag)
|
LocationMatch = group.Max(row => row.LocationFlag),
|
||||||
|
AreaMatch = group.Max(row => row.AreaFlag)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each facet selects the group when any status-eligible row matches it
|
// Each facet selects the group when any status-eligible row matches it
|
||||||
|
|
@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
if (selectedLocations is { Length: > 0 })
|
if (selectedLocations is { Length: > 0 })
|
||||||
grouped = grouped.Where(key => key.LocationMatch == 1);
|
grouped = grouped.Where(key => key.LocationMatch == 1);
|
||||||
|
|
||||||
|
if (areaFilter != null)
|
||||||
|
grouped = grouped.Where(key => key.AreaMatch == 1);
|
||||||
|
|
||||||
var selectedJobBuckets = jobBuckets?
|
var selectedJobBuckets = jobBuckets?
|
||||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
.Select(value => value.Trim().ToLower())
|
.Select(value => value.Trim().ToLower())
|
||||||
|
|
@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
string? search,
|
string? search,
|
||||||
IReadOnlyCollection<string>? companies,
|
IReadOnlyCollection<string>? companies,
|
||||||
IReadOnlyCollection<string>? trades,
|
IReadOnlyCollection<string>? trades,
|
||||||
IReadOnlyCollection<string>? locations)
|
IReadOnlyCollection<string>? locations,
|
||||||
|
VendorAreaFilter? areaFilter)
|
||||||
{
|
{
|
||||||
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
||||||
|
|
||||||
|
|
@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var locationBody = selectedLocations is { Length: > 0 }
|
var locationBody = selectedLocations is { Length: > 0 }
|
||||||
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
||||||
: null;
|
: null;
|
||||||
|
var areaBody = areaFilter == null
|
||||||
|
? null
|
||||||
|
: BuildAreaMatchBody(row, areaFilter);
|
||||||
|
|
||||||
var nameKey = Expression.Condition(
|
var nameKey = Expression.Condition(
|
||||||
Expression.NotEqual(
|
Expression.NotEqual(
|
||||||
|
|
@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
|
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))),
|
||||||
row);
|
row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
CompanyCity = v.Company != null ? v.Company.City : null,
|
CompanyCity = v.Company != null ? v.Company.City : null,
|
||||||
CompanyState = v.Company != null ? v.Company.State : null,
|
CompanyState = v.Company != null ? v.Company.State : null,
|
||||||
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
||||||
|
CompanyAreaId = v.Company != null ? v.Company.AreaId : null,
|
||||||
|
CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null,
|
||||||
ContactName = v.ContactName,
|
ContactName = v.ContactName,
|
||||||
Email = v.Email,
|
Email = v.Email,
|
||||||
Phone = v.Phone,
|
Phone = v.Phone,
|
||||||
|
|
@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = v.TradeSpecialties,
|
TradeSpecialties = v.TradeSpecialties,
|
||||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||||
Notes = v.Company != null ? v.Company.Notes : null,
|
Notes = v.Company != null ? v.Company.Notes : null,
|
||||||
|
AreaId = v.Company != null ? v.Company.AreaId : null,
|
||||||
IsActive = v.IsActive,
|
IsActive = v.IsActive,
|
||||||
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
||||||
};
|
};
|
||||||
|
|
@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = null,
|
TradeSpecialties = null,
|
||||||
GoogleMapsUrl = c.GoogleMapsUrl,
|
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||||
Notes = c.Notes,
|
Notes = c.Notes,
|
||||||
|
AreaId = c.AreaId,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
TotalJobs = 0
|
TotalJobs = 0
|
||||||
});
|
});
|
||||||
|
|
@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return body!;
|
return body!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: OR within the Area facet. Selected ids match the company's stable
|
||||||
|
// AreaId; the Unassigned sentinel matches rows without an Area, which includes
|
||||||
|
// unlinked legacy vendors that have no VendorCompany at all. A filter with no
|
||||||
|
// resolvable ids and no Unassigned matches nobody.
|
||||||
|
private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter)
|
||||||
|
{
|
||||||
|
var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId));
|
||||||
|
Expression? body = null;
|
||||||
|
|
||||||
|
if (areaFilter.AreaIds.Count > 0)
|
||||||
|
{
|
||||||
|
var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray();
|
||||||
|
body = OrElse(body, Expression.Call(
|
||||||
|
typeof(Enumerable),
|
||||||
|
nameof(Enumerable.Contains),
|
||||||
|
new[] { typeof(int?) },
|
||||||
|
Expression.Constant(ids),
|
||||||
|
areaId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areaFilter.IncludeUnassigned)
|
||||||
|
body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?))));
|
||||||
|
|
||||||
|
return body ?? Expression.Constant(false);
|
||||||
|
}
|
||||||
|
|
||||||
private static Expression BuildCompanyNameMatchBody(
|
private static Expression BuildCompanyNameMatchBody(
|
||||||
ParameterExpression row,
|
ParameterExpression row,
|
||||||
IReadOnlyCollection<string> selectedCompanies)
|
IReadOnlyCollection<string> selectedCompanies)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
public interface IVendorCompanyRosterDataService
|
public interface IVendorCompanyRosterDataService
|
||||||
{
|
{
|
||||||
|
// SH-278: true only for an existing, non-archived Area in the catalogue.
|
||||||
|
Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
// Loads the company roster from either a vendor row id and/or a company id,
|
// Loads the company roster from either a vendor row id and/or a company id,
|
||||||
// returning the company plus all non-deleted technician rows.
|
// returning the company plus all non-deleted technician rows.
|
||||||
Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
// of technician Vendor.TradeSpecialties free text.
|
// of technician Vendor.TradeSpecialties free text.
|
||||||
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// SH-278: active (non-archived) Area catalogue rows for vendor facets and for
|
||||||
|
// resolving areas[n] filter ids. Archived Areas are excluded.
|
||||||
|
Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
||||||
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
||||||
// companies; active includes companies with zero non-deleted technicians or at
|
// companies; active includes companies with zero non-deleted technicians or at
|
||||||
|
|
@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
VendorAreaFilter? areaFilter = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<Vendor> AddAsync(Vendor vendor);
|
Task<Vendor> AddAsync(Vendor vendor);
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public byte[]? RowVersion { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
|
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
|
||||||
|
public bool AreaIdProvided { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
public string? ActorUserId { get; set; }
|
public string? ActorUserId { get; set; }
|
||||||
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
|
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
|
||||||
|
public bool AreaIdProvided { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raised by the roster data service when a technician removed from the snapshot
|
// Raised by the roster data service when a technician removed from the snapshot
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: Area facet filter for the company directory. AreaIds are already
|
||||||
|
// resolved against the active catalogue; IncludeUnassigned selects groups with no
|
||||||
|
// Area (including unlinked legacy vendors). An empty filter matches nobody.
|
||||||
|
public sealed class VendorAreaFilter
|
||||||
|
{
|
||||||
|
public IReadOnlyCollection<int> AreaIds { get; init; } = Array.Empty<int>();
|
||||||
|
public bool IncludeUnassigned { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
||||||
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
||||||
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
||||||
|
|
@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models
|
||||||
public int CompanyFlag { get; set; }
|
public int CompanyFlag { get; set; }
|
||||||
public int TradeFlag { get; set; }
|
public int TradeFlag { get; set; }
|
||||||
public int LocationFlag { get; set; }
|
public int LocationFlag { get; set; }
|
||||||
|
public int AreaFlag { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
||||||
|
|
@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models
|
||||||
public int CompanyMatch { get; set; }
|
public int CompanyMatch { get; set; }
|
||||||
public int TradeMatch { get; set; }
|
public int TradeMatch { get; set; }
|
||||||
public int LocationMatch { get; set; }
|
public int LocationMatch { get; set; }
|
||||||
|
public int AreaMatch { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
||||||
|
|
@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? CompanyCity { get; set; }
|
public string? CompanyCity { get; set; }
|
||||||
public string? CompanyState { get; set; }
|
public string? CompanyState { get; set; }
|
||||||
public string? CompanyNotes { get; set; }
|
public string? CompanyNotes { get; set; }
|
||||||
|
public int? CompanyAreaId { get; set; }
|
||||||
|
public string? CompanyAreaName { get; set; }
|
||||||
public string? ContactName { get; set; }
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
|
@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SeaHaven.Services.DTOs
|
namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public class VendorRosterDTO
|
public class VendorRosterDTO
|
||||||
|
|
@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public string? RowVersion { get; set; }
|
public string? RowVersion { get; set; }
|
||||||
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
|
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: optional Area id; null leaves the new company Unassigned. Assigning
|
||||||
|
// an Area requires the Admin role.
|
||||||
|
public int? AreaId { get; set; }
|
||||||
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: an omitted areaId leaves the stored Area unchanged, so clients that
|
||||||
|
// predate Area never clear it. An explicit null clears it to Unassigned.
|
||||||
|
private int? _areaId;
|
||||||
|
public int? AreaId
|
||||||
|
{
|
||||||
|
get => _areaId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_areaId = value;
|
||||||
|
AreaIdProvided = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool AreaIdProvided { get; private set; }
|
||||||
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId.
|
||||||
|
private int? _areaId;
|
||||||
|
public int? AreaId
|
||||||
|
{
|
||||||
|
get => _areaId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_areaId = value;
|
||||||
|
AreaIdProvided = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool AreaIdProvided { get; private set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-278: raised when a caller without the Admin role tries to change a vendor
|
||||||
|
// company's Area. Mapped to a stable 403 by the API.
|
||||||
|
public sealed class VendorAreaAssignmentForbiddenException : Exception
|
||||||
|
{
|
||||||
|
public VendorAreaAssignmentForbiddenException()
|
||||||
|
: base("Only administrators can assign a vendor company Area.")
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,21 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
|
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
|
||||||
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
|
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
|
||||||
|
public IEnumerable<VendorFacetAreaDTO> Areas { get; set; } = Enumerable.Empty<VendorFacetAreaDTO>();
|
||||||
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
|
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
|
||||||
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
|
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: one active Area option. Id is the stable areas[n] filter value.
|
||||||
|
public class VendorFacetAreaDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-278: wire values accepted by the vendor directory areas[n] filter besides ids.
|
||||||
|
public static class VendorAreaFilterValues
|
||||||
|
{
|
||||||
|
public const string Unassigned = "__unassigned__";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.Validation;
|
using SeaHaven.Services.Validation;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -13,6 +14,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
private const int MaxNotesLength = 500;
|
private const int MaxNotesLength = 500;
|
||||||
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
|
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
|
||||||
|
private const string AdminRole = "Admin";
|
||||||
|
private const string AreaNotFoundMessage = "Area was not found.";
|
||||||
|
|
||||||
private readonly IVendorCompanyRosterDataService _rosterDataService;
|
private readonly IVendorCompanyRosterDataService _rosterDataService;
|
||||||
|
|
||||||
|
|
@ -39,6 +42,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
public async Task<VendorRosterDTO> CreateRosterAsync(
|
public async Task<VendorRosterDTO> CreateRosterAsync(
|
||||||
CreateVendorRosterDTO dto,
|
CreateVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(userId);
|
EnsureAuthenticated(userId);
|
||||||
|
|
@ -60,6 +64,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
"Technician ids are not allowed when creating a vendor company.")
|
"Technician ids are not allowed when creating a vendor company.")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await EnsureAreaAssignmentAllowedAsync(
|
||||||
|
caller,
|
||||||
|
dto.AreaId,
|
||||||
|
currentAreaId: null,
|
||||||
|
nameof(CreateVendorRosterDTO.AreaId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var writeModel = new VendorCompanyRosterWriteModel
|
var writeModel = new VendorCompanyRosterWriteModel
|
||||||
{
|
{
|
||||||
Name = dto.Name,
|
Name = dto.Name,
|
||||||
|
|
@ -71,6 +82,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = dto.Zip,
|
Zip = dto.Zip,
|
||||||
GoogleMapsUrl = dto.GoogleMapsUrl,
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
Notes = dto.Notes,
|
Notes = dto.Notes,
|
||||||
|
AreaIdProvided = true,
|
||||||
|
AreaId = dto.AreaId,
|
||||||
ActorUserId = userId,
|
ActorUserId = userId,
|
||||||
Technicians = MapTechnicians(dto.Technicians)
|
Technicians = MapTechnicians(dto.Technicians)
|
||||||
};
|
};
|
||||||
|
|
@ -83,6 +96,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
int companyId,
|
int companyId,
|
||||||
ReconcileVendorRosterDTO dto,
|
ReconcileVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(userId);
|
EnsureAuthenticated(userId);
|
||||||
|
|
@ -101,6 +115,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
// Mismatched ids: every non-null technician id must belong to this company.
|
// Mismatched ids: every non-null technician id must belong to this company.
|
||||||
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
|
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
|
||||||
|
|
||||||
|
if (dto.AreaIdProvided)
|
||||||
|
await EnsureAreaChangeAllowedAsync(
|
||||||
|
companyId,
|
||||||
|
caller,
|
||||||
|
dto.AreaId,
|
||||||
|
nameof(ReconcileVendorRosterDTO.AreaId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var writeModel = new VendorCompanyRosterWriteModel
|
var writeModel = new VendorCompanyRosterWriteModel
|
||||||
{
|
{
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
|
|
@ -114,6 +136,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = dto.Zip,
|
Zip = dto.Zip,
|
||||||
GoogleMapsUrl = dto.GoogleMapsUrl,
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
Notes = dto.Notes,
|
Notes = dto.Notes,
|
||||||
|
AreaIdProvided = dto.AreaIdProvided,
|
||||||
|
AreaId = dto.AreaId,
|
||||||
ActorUserId = userId,
|
ActorUserId = userId,
|
||||||
Technicians = MapTechnicians(dto.Technicians)
|
Technicians = MapTechnicians(dto.Technicians)
|
||||||
};
|
};
|
||||||
|
|
@ -126,6 +150,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
int companyId,
|
int companyId,
|
||||||
AddTechniciansVendorRosterDTO dto,
|
AddTechniciansVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(userId);
|
EnsureAuthenticated(userId);
|
||||||
|
|
@ -167,6 +192,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (failures.Count > 0)
|
if (failures.Count > 0)
|
||||||
throw new ValidationException(failures);
|
throw new ValidationException(failures);
|
||||||
|
|
||||||
|
if (companyFields is { AreaIdProvided: true })
|
||||||
|
await EnsureAreaChangeAllowedAsync(
|
||||||
|
companyId,
|
||||||
|
caller,
|
||||||
|
companyFields.AreaId,
|
||||||
|
$"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
// The contact-group invariant ("at least one company phone or email") is
|
// The contact-group invariant ("at least one company phone or email") is
|
||||||
// preserved by construction: blank company fields map to null ("unchanged"),
|
// preserved by construction: blank company fields map to null ("unchanged"),
|
||||||
// so this endpoint can only set valid phone/email values, never clear them.
|
// so this endpoint can only set valid phone/email values, never clear them.
|
||||||
|
|
@ -184,6 +217,50 @@ namespace SeaHaven.Services.Implementation
|
||||||
return MapToDTO(saved);
|
return MapToDTO(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: Area assignment is Admin-only. A payload that leaves the stored Area
|
||||||
|
// unchanged is allowed for any authenticated caller, so non-admin users can
|
||||||
|
// still save other company fields while round-tripping the current Area.
|
||||||
|
private async Task EnsureAreaChangeAllowedAsync(
|
||||||
|
int companyId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
int? requestedAreaId,
|
||||||
|
string propertyName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found.");
|
||||||
|
|
||||||
|
await EnsureAreaAssignmentAllowedAsync(
|
||||||
|
caller,
|
||||||
|
requestedAreaId,
|
||||||
|
roster.AreaId,
|
||||||
|
propertyName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureAreaAssignmentAllowedAsync(
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
int? requestedAreaId,
|
||||||
|
int? currentAreaId,
|
||||||
|
string propertyName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (requestedAreaId == currentAreaId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (caller?.IsInRole(AdminRole) != true)
|
||||||
|
throw new VendorAreaAssignmentForbiddenException();
|
||||||
|
|
||||||
|
if (requestedAreaId is int areaId
|
||||||
|
&& (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken)))
|
||||||
|
{
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure(propertyName, AreaNotFoundMessage)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureAuthenticated(string userId)
|
private static void EnsureAuthenticated(string userId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
|
@ -290,7 +367,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
|| !string.IsNullOrWhiteSpace(fields.State)
|
|| !string.IsNullOrWhiteSpace(fields.State)
|
||||||
|| !string.IsNullOrWhiteSpace(fields.Zip)
|
|| !string.IsNullOrWhiteSpace(fields.Zip)
|
||||||
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|
||||||
|| !string.IsNullOrWhiteSpace(fields.Notes);
|
|| !string.IsNullOrWhiteSpace(fields.Notes)
|
||||||
|
|| fields.AreaIdProvided;
|
||||||
|
|
||||||
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
|
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
|
||||||
model.Name == null
|
model.Name == null
|
||||||
|
|
@ -301,7 +379,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
&& model.State == null
|
&& model.State == null
|
||||||
&& model.Zip == null
|
&& model.Zip == null
|
||||||
&& model.GoogleMapsUrl == null
|
&& model.GoogleMapsUrl == null
|
||||||
&& model.Notes == null;
|
&& model.Notes == null
|
||||||
|
&& !model.AreaIdProvided;
|
||||||
|
|
||||||
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
|
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
|
||||||
VendorRosterCompanyFieldsDTO fields,
|
VendorRosterCompanyFieldsDTO fields,
|
||||||
|
|
@ -344,7 +423,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
|
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
|
||||||
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
|
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
|
||||||
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
|
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
|
||||||
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes
|
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes,
|
||||||
|
AreaIdProvided = fields.AreaIdProvided,
|
||||||
|
AreaId = fields.AreaId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,6 +524,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = roster.Zip,
|
Zip = roster.Zip,
|
||||||
GoogleMapsUrl = roster.GoogleMapsUrl,
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
||||||
Notes = roster.Notes,
|
Notes = roster.Notes,
|
||||||
|
AreaId = roster.AreaId,
|
||||||
|
AreaName = roster.AreaName,
|
||||||
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
|
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
|
||||||
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
|
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using FluentValidation;
|
||||||
using FluentValidation.Results;
|
using FluentValidation.Results;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Globalization;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
|
@ -160,10 +161,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
IReadOnlyCollection<string>? areas = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var normalizedPage = Math.Max(1, page);
|
var normalizedPage = Math.Max(1, page);
|
||||||
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
||||||
|
var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken);
|
||||||
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
||||||
normalizedPage,
|
normalizedPage,
|
||||||
normalizedPageSize,
|
normalizedPageSize,
|
||||||
|
|
@ -173,6 +176,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
trades,
|
trades,
|
||||||
locations,
|
locations,
|
||||||
jobBuckets,
|
jobBuckets,
|
||||||
|
areaFilter,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return new PagedResult<VendorDirectoryItemDTO>
|
return new PagedResult<VendorDirectoryItemDTO>
|
||||||
|
|
@ -184,6 +188,47 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects
|
||||||
|
// companies without an Area; other values must be stable positive integer ids
|
||||||
|
// of active catalogue rows. Display names, unknown ids and archived ids never
|
||||||
|
// error — they simply match nobody. Null means no Area filter was requested.
|
||||||
|
private async Task<VendorAreaFilter?> ResolveAreaFilterAsync(
|
||||||
|
IReadOnlyCollection<string>? areas,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var values = areas?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (values is not { Count: > 0 })
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal);
|
||||||
|
var requestedIds = new HashSet<int>();
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0)
|
||||||
|
requestedIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyCollection<int> activeIds = Array.Empty<int>();
|
||||||
|
if (requestedIds.Count > 0)
|
||||||
|
{
|
||||||
|
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
|
||||||
|
activeIds = activeAreas
|
||||||
|
.Select(area => area.Id)
|
||||||
|
.Where(requestedIds.Contains)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VendorAreaFilter
|
||||||
|
{
|
||||||
|
AreaIds = activeIds,
|
||||||
|
IncludeUnassigned = includeUnassigned
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
||||||
{
|
{
|
||||||
Id = group.Id,
|
Id = group.Id,
|
||||||
|
|
@ -201,6 +246,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
TradeSpecialties = group.TradeSpecialties,
|
TradeSpecialties = group.TradeSpecialties,
|
||||||
GoogleMapsUrl = group.GoogleMapsUrl,
|
GoogleMapsUrl = group.GoogleMapsUrl,
|
||||||
Notes = group.Notes,
|
Notes = group.Notes,
|
||||||
|
AreaId = group.AreaId,
|
||||||
|
AreaName = group.AreaName,
|
||||||
IsActive = group.IsActive,
|
IsActive = group.IsActive,
|
||||||
TotalJobs = group.TotalJobs,
|
TotalJobs = group.TotalJobs,
|
||||||
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
||||||
|
|
@ -731,6 +778,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
// populated even on an empty database and independent of the status filter.
|
// populated even on an empty database and independent of the status filter.
|
||||||
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
||||||
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
|
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
|
||||||
|
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
|
||||||
|
|
||||||
var companyFacets = companies
|
var companyFacets = companies
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
|
|
@ -772,10 +820,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(location => location.Label);
|
.OrderBy(location => location.Label);
|
||||||
|
|
||||||
|
// SH-278: the active Area catalogue, A–Z case-insensitive, independent of the
|
||||||
|
// status filter and of whether any company currently uses an Area. Clients
|
||||||
|
// prepend the "__unassigned__" option themselves.
|
||||||
|
var areas = activeAreas
|
||||||
|
.OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return new VendorFacetsDTO
|
return new VendorFacetsDTO
|
||||||
{
|
{
|
||||||
Companies = companyFacets,
|
Companies = companyFacets,
|
||||||
Trades = trades,
|
Trades = trades,
|
||||||
|
Areas = areas,
|
||||||
Locations = locations,
|
Locations = locations,
|
||||||
JobBuckets = new[]
|
JobBuckets = new[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Interfaces
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
|
@ -13,18 +14,21 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<VendorRosterDTO> CreateRosterAsync(
|
Task<VendorRosterDTO> CreateRosterAsync(
|
||||||
CreateVendorRosterDTO dto,
|
CreateVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<VendorRosterDTO> ReconcileRosterAsync(
|
Task<VendorRosterDTO> ReconcileRosterAsync(
|
||||||
int companyId,
|
int companyId,
|
||||||
ReconcileVendorRosterDTO dto,
|
ReconcileVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<VendorRosterDTO> AddTechniciansAsync(
|
Task<VendorRosterDTO> AddTechniciansAsync(
|
||||||
int companyId,
|
int companyId,
|
||||||
AddTechniciansVendorRosterDTO dto,
|
AddTechniciansVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
IReadOnlyCollection<string>? areas = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue