mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
3 commits
9cb7b8dc82
...
436e615510
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436e615510 | ||
|
|
3a4af64a64 | ||
|
|
67089c2135 |
24 changed files with 1799 additions and 110 deletions
|
|
@ -4,6 +4,7 @@ using Api.SeaHavenIndustries.DTOs;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using FluentValidation.Results;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
@ -25,7 +26,7 @@ public class LocationControllerTests
|
||||||
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
||||||
{
|
{
|
||||||
var service = new Mock<ILocationService>();
|
var service = new Mock<ILocationService>();
|
||||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny<CancellationToken>()))
|
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||||
{
|
{
|
||||||
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
||||||
|
|
@ -34,7 +35,7 @@ public class LocationControllerTests
|
||||||
PageSize = 10
|
PageSize = 10
|
||||||
});
|
});
|
||||||
|
|
||||||
var result = await NewController(service).GetLocationList("a", 1, 10, CancellationToken.None);
|
var result = await NewController(service).GetLocationList("a", 1, 10, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
|
@ -44,6 +45,43 @@ public class LocationControllerTests
|
||||||
envelope.TotalPages.Should().Be(1);
|
envelope.TotalPages.Should().Be(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationList_PassesStatesFilterToService()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||||
|
{
|
||||||
|
Items = new List<LocationDTO>(),
|
||||||
|
TotalCount = 0,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
service.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Validation Error");
|
||||||
|
response.Message.Should().Contain("ZZ");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity()
|
public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
89
Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs
Normal file
89
Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class LocationDataServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedAsync(ApplicationDbContext ctx)
|
||||||
|
{
|
||||||
|
ctx.Locations.AddRange(
|
||||||
|
new Locations { Id = 1, Name = "Alpha Site", City = "Austin", State = "TX" },
|
||||||
|
new Locations { Id = 2, Name = "Beta Site", City = "Dallas", State = "TX" },
|
||||||
|
new Locations { Id = 3, Name = "Gamma Yard", City = "Kansas City", State = "MO" },
|
||||||
|
new Locations { Id = 4, Name = "Delta Hub", City = "Indianapolis", State = "indiana" },
|
||||||
|
new Locations { Id = 5, Name = "Epsilon Depot", City = "Houston", State = "TX" },
|
||||||
|
new Locations { Id = 6, Name = "Zeta Lot", City = "Nowhere", State = null });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_WithoutStates_ReturnsAllRows()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedAsync(ctx);
|
||||||
|
|
||||||
|
var (items, totalCount) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
totalCount.Should().Be(6);
|
||||||
|
items.Should().HaveCount(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_AppliesStatesFilterBeforeCountAndPaging()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedAsync(ctx);
|
||||||
|
|
||||||
|
var (page1, totalCount) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 2, null, new[] { "TX" }, CancellationToken.None);
|
||||||
|
|
||||||
|
totalCount.Should().Be(3, "count must reflect the state filter, not the whole table");
|
||||||
|
page1.Should().HaveCount(2);
|
||||||
|
page1.Select(l => l.Name).Should().ContainInOrder("Alpha Site", "Beta Site");
|
||||||
|
|
||||||
|
var (page2, totalCountAgain) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(2, 2, null, new[] { "TX" }, CancellationToken.None);
|
||||||
|
|
||||||
|
totalCountAgain.Should().Be(3);
|
||||||
|
page2.Select(l => l.Name).Should().ContainSingle("Epsilon Depot");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_MultipleStates_KeepSearchFilter()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedAsync(ctx);
|
||||||
|
|
||||||
|
var (items, totalCount) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, "Dallas", new[] { "TX", "MO" }, CancellationToken.None);
|
||||||
|
|
||||||
|
totalCount.Should().Be(1);
|
||||||
|
items.Should().ContainSingle().Which.Name.Should().Be("Beta Site");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_StatesFilter_ExcludesNullAndNonMatchingStates()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedAsync(ctx);
|
||||||
|
|
||||||
|
var (items, totalCount) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, null, new[] { "IN", "INDIANA", "MO", "MISSOURI" }, CancellationToken.None);
|
||||||
|
|
||||||
|
totalCount.Should().Be(2);
|
||||||
|
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -118,7 +118,7 @@ public class LocationServiceTests
|
||||||
SeedLocation(ctx, "Beta Site", "Dallas");
|
SeedLocation(ctx, "Beta Site", "Dallas");
|
||||||
SeedLocation(ctx, "Gamma Yard", "Austin");
|
SeedLocation(ctx, "Gamma Yard", "Austin");
|
||||||
|
|
||||||
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None);
|
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
page.Items.Should().HaveCount(2);
|
page.Items.Should().HaveCount(2);
|
||||||
page.TotalCount.Should().Be(2);
|
page.TotalCount.Should().Be(2);
|
||||||
|
|
@ -126,6 +126,93 @@ public class LocationServiceTests
|
||||||
page.Items.Select(l => l.Name).Should().NotBeNull();
|
page.Items.Select(l => l.Name).Should().NotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_NormalizesStates_CaseWhitespaceAndDuplicates()
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
IReadOnlyCollection<string>? captured = default;
|
||||||
|
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
|
||||||
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
|
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured.Should().BeEquivalentTo(new[] { "TX", "TEXAS", "MO", "MISSOURI" });
|
||||||
|
captured.Should().HaveCount(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_EmptyOrWhitespaceStates_PassesNoStateFilter()
|
||||||
|
{
|
||||||
|
foreach (var states in new[] { null, "", " ", " , , " })
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
IReadOnlyCollection<string>? captured = default;
|
||||||
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
|
||||||
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
|
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Should().BeNull($"states input '{states}' must mean no filter");
|
||||||
|
page.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_InvalidStates_ThrowsValidationAndNeverQueries()
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
|
||||||
|
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||||
|
.Which.Errors.Should().ContainSingle(e =>
|
||||||
|
e.PropertyName == "states"
|
||||||
|
&& e.ErrorMessage.Contains("ZZ")
|
||||||
|
&& e.ErrorMessage.Contains("QQ")
|
||||||
|
&& !e.ErrorMessage.Contains("TX"));
|
||||||
|
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
|
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
|
||||||
|
|
||||||
|
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, allStates, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().NotThrowAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_AppliesNormalizedStatesThroughDataService()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedLocation(ctx, "Alpha Site", "Austin").State = "TX";
|
||||||
|
SeedLocation(ctx, "Beta Site", "Dallas").State = "TX";
|
||||||
|
SeedLocation(ctx, "Gamma Yard", "Kansas City").State = "MO";
|
||||||
|
SeedLocation(ctx, "Delta Hub", "Indianapolis").State = "IN";
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, " mo , tx ", CancellationToken.None);
|
||||||
|
|
||||||
|
page.TotalCount.Should().Be(3);
|
||||||
|
page.Items.Select(l => l.Name).Should().BeEquivalentTo("Alpha Site", "Beta Site", "Gamma Yard");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationService NewServiceWithSpy(ILocationDataService dataService) =>
|
||||||
|
new(
|
||||||
|
dataService,
|
||||||
|
Mock.Of<IAccountDataService>(),
|
||||||
|
new CreateLocationValidation(),
|
||||||
|
new UpdateLocationValidation());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
|
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Reflection;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Api.SeaHavenIndustries.Observability;
|
using Api.SeaHavenIndustries.Observability;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
|
@ -14,6 +15,7 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
public class SentryObservabilityTests
|
public class SentryObservabilityTests
|
||||||
{
|
{
|
||||||
private static readonly string ValidSha = new string('a', 40);
|
private static readonly string ValidSha = new string('a', 40);
|
||||||
|
private static readonly string UpperSha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveRelease_Accepts_ServicePrefixed40HexCommit()
|
public void ResolveRelease_Accepts_ServicePrefixed40HexCommit()
|
||||||
|
|
@ -23,15 +25,57 @@ public class SentryObservabilityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
[InlineData("1.0.0")]
|
[InlineData("1.0.0")]
|
||||||
[InlineData("shoc-backend@abc")]
|
[InlineData("shoc-backend@abc")]
|
||||||
[InlineData("shoc-backend@not-a-sha-at-all")]
|
[InlineData("shoc-backend@not-a-sha-at-all")]
|
||||||
[InlineData("other-service@" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
[InlineData("other-service@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||||
|
[InlineData("SHOC-BACKEND@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
public void ResolveRelease_FallsBackToLocalDevelopment_ForNonReleaseVersion(string version)
|
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||||
|
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
||||||
|
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")]
|
||||||
|
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+dirty")]
|
||||||
|
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+")]
|
||||||
|
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa++")]
|
||||||
|
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+extra")]
|
||||||
|
public void ResolveRelease_FallsBackToLocalDevelopment_ForNonReleaseVersion(string? version)
|
||||||
{
|
{
|
||||||
SentryObservability.ResolveRelease(version)
|
SentryObservability.ResolveRelease(version)
|
||||||
.Should().Be(SentryObservability.LocalDevelopmentRelease);
|
.Should().Be(SentryObservability.LocalDevelopmentRelease);
|
||||||
|
SentryObservability.ResolveCommitSha(version)
|
||||||
|
.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveRelease_Accepts_SdkAppendedMatchingSourceRevisionSuffix()
|
||||||
|
{
|
||||||
|
var informationalVersion = "shoc-backend@" + ValidSha + "+" + ValidSha;
|
||||||
|
|
||||||
|
SentryObservability.ResolveRelease(informationalVersion)
|
||||||
|
.Should().Be("shoc-backend@" + ValidSha);
|
||||||
|
SentryObservability.ResolveCommitSha(informationalVersion)
|
||||||
|
.Should().Be(ValidSha);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveRelease_Accepts_SuffixDifferingOnlyInCase_FromTheCommitSha()
|
||||||
|
{
|
||||||
|
var canonicalSha = UpperSha.ToLowerInvariant();
|
||||||
|
|
||||||
|
SentryObservability.ResolveRelease("shoc-backend@" + UpperSha + "+" + canonicalSha)
|
||||||
|
.Should().Be("shoc-backend@" + canonicalSha);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveRelease_NormalizesUppercaseCommitShaToLowercase()
|
||||||
|
{
|
||||||
|
var canonicalSha = UpperSha.ToLowerInvariant();
|
||||||
|
|
||||||
|
SentryObservability.ResolveRelease("shoc-backend@" + UpperSha)
|
||||||
|
.Should().Be("shoc-backend@" + canonicalSha);
|
||||||
|
SentryObservability.ResolveCommitSha("shoc-backend@" + UpperSha)
|
||||||
|
.Should().Be(canonicalSha);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -39,6 +83,28 @@ public class SentryObservabilityTests
|
||||||
{
|
{
|
||||||
var release = SentryObservability.ResolveRelease("shoc-backend@" + ValidSha);
|
var release = SentryObservability.ResolveRelease("shoc-backend@" + ValidSha);
|
||||||
release[(SentryObservability.ServiceName.Length + 1)..].Should().Be(ValidSha);
|
release[(SentryObservability.ServiceName.Length + 1)..].Should().Be(ValidSha);
|
||||||
|
SentryObservability.ResolveCommitSha("shoc-backend@" + ValidSha)
|
||||||
|
.Should().Be(ValidSha);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveRelease_AndCommitSha_FromNonReleaseAssembly_FallBackSafely()
|
||||||
|
{
|
||||||
|
var assembly = typeof(SentryObservabilityTests).Assembly;
|
||||||
|
|
||||||
|
SentryObservability.ResolveRelease(assembly)
|
||||||
|
.Should().Be(SentryObservability.LocalDevelopmentRelease);
|
||||||
|
SentryObservability.ResolveCommitSha(assembly)
|
||||||
|
.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveRelease_AndCommitSha_FromNullAssembly_FallBackSafely()
|
||||||
|
{
|
||||||
|
SentryObservability.ResolveRelease((Assembly?)null)
|
||||||
|
.Should().Be(SentryObservability.LocalDevelopmentRelease);
|
||||||
|
SentryObservability.ResolveCommitSha((Assembly?)null)
|
||||||
|
.Should().BeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ public class VendorControllerTests
|
||||||
var service = new Mock<IVendorService>();
|
var service = new Mock<IVendorService>();
|
||||||
using var cancellation = new CancellationTokenSource();
|
using var cancellation = new CancellationTokenSource();
|
||||||
var token = cancellation.Token;
|
var token = cancellation.Token;
|
||||||
service.Setup(x => x.GetVendorDirectoryPagedAsync(
|
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
10,
|
10,
|
||||||
"clayton",
|
"clayton",
|
||||||
|
|
@ -53,7 +53,7 @@ public class VendorControllerTests
|
||||||
|
|
||||||
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
|
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
|
||||||
|
|
||||||
service.Verify(x => x.GetVendorDirectoryPagedAsync(
|
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
10,
|
10,
|
||||||
"clayton",
|
"clayton",
|
||||||
|
|
@ -63,6 +63,175 @@ public class VendorControllerTests
|
||||||
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.GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
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<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorService>();
|
||||||
|
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
|
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<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||||
|
{
|
||||||
|
Items = new List<VendorDirectoryItemDTO>
|
||||||
|
{
|
||||||
|
new() { Id = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
|
||||||
|
new() { Id = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
|
||||||
|
},
|
||||||
|
TotalCount = 2,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).GetVendorList();
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
var rows = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().ToList();
|
||||||
|
|
||||||
|
// Legacy contract: same-company technicians stay as distinct top-level rows
|
||||||
|
// carrying their own vendor ids, and the response shape has no grouped
|
||||||
|
// Technicians property.
|
||||||
|
rows.Should().HaveCount(2);
|
||||||
|
rows.Select(r => r.GetType().GetProperty("Id")!.GetValue(r)).Should().BeEquivalentTo(new[] { 7, 9 });
|
||||||
|
rows.Should().OnlyContain(r => r.GetType().GetProperty("Technicians") == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorList_PreservesLegacyPageSizeAboveGroupedDirectoryBound()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorService>();
|
||||||
|
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
500,
|
||||||
|
It.IsAny<string?>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO> { TotalCount = 420, Page = 1, PageSize = 500 });
|
||||||
|
|
||||||
|
var result = await NewController(service).GetVendorList(page: 1, pageSize: 500);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
paged.PageSize.Should().Be(500);
|
||||||
|
paged.TotalPages.Should().Be(1);
|
||||||
|
service.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorService>();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var token = cancellation.Token;
|
||||||
|
service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token))
|
||||||
|
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||||
|
{
|
||||||
|
Items = new List<VendorDirectoryItemDTO>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 9,
|
||||||
|
CompanyId = 3,
|
||||||
|
CompanyName = "Gateway Plumbing",
|
||||||
|
Email = "company@gatewayplumbing.example",
|
||||||
|
TotalJobs = 6,
|
||||||
|
Technicians = new List<VendorDirectoryTechnicianDTO>
|
||||||
|
{
|
||||||
|
new() { Id = 7, ContactName = "Tech A", Email = "tech.a@example.com" },
|
||||||
|
new() { Id = 9, ContactName = "Tech B", Email = "tech.b@example.com" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TotalCount = 1,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).GetVendorDirectoryList(search: "clayton", cancellationToken: token);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
var company = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().Single();
|
||||||
|
|
||||||
|
// SH-281: company row with nested technicians; top-level Email stays the
|
||||||
|
// company email while technician emails stay on the nested rows.
|
||||||
|
company.GetType().GetProperty("Email")!.GetValue(company).Should().Be("company@gatewayplumbing.example");
|
||||||
|
var technicians = (System.Collections.IEnumerable)company.GetType().GetProperty("Technicians")!.GetValue(company)!;
|
||||||
|
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Id")!.GetValue(t))
|
||||||
|
.Should().BeEquivalentTo(new[] { 7, 9 });
|
||||||
|
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Email")!.GetValue(t))
|
||||||
|
.Should().BeEquivalentTo(new[] { "tech.a@example.com", "tech.b@example.com" });
|
||||||
|
|
||||||
|
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token), Times.Once);
|
||||||
|
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
|
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<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes()
|
||||||
|
{
|
||||||
|
var legacyRoutes = typeof(VendorController)
|
||||||
|
.GetMethod(nameof(VendorController.GetVendorList))!
|
||||||
|
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
|
||||||
|
.Cast<HttpGetAttribute>()
|
||||||
|
.Select(attribute => attribute.Template);
|
||||||
|
var companyRoutes = typeof(VendorController)
|
||||||
|
.GetMethod(nameof(VendorController.GetVendorDirectoryList))!
|
||||||
|
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
|
||||||
|
.Cast<HttpGetAttribute>()
|
||||||
|
.Select(attribute => attribute.Template);
|
||||||
|
|
||||||
|
legacyRoutes.Should().BeEquivalentTo("GetVendorList");
|
||||||
|
companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using FluentAssertions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -327,6 +328,158 @@ public class VendorDataServiceTests
|
||||||
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompanyDirectoryPagedAsync_GroupsCompaniesAndKeepsLegacyRowsDistinct()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var linkedCompany = new VendorCompany
|
||||||
|
{
|
||||||
|
Name = "Linked Co",
|
||||||
|
NormalizedName = "linked co",
|
||||||
|
Email = "dispatch@linked.example",
|
||||||
|
CompanyPhone = "555-0100",
|
||||||
|
Address = "100 Company Way",
|
||||||
|
City = "Chicago",
|
||||||
|
State = "IL"
|
||||||
|
};
|
||||||
|
var companyOnly = new VendorCompany { Name = "Company Only", NormalizedName = "company only" };
|
||||||
|
context.VendorCompanies.AddRange(linkedCompany, companyOnly);
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
new Vendor
|
||||||
|
{
|
||||||
|
Company = linkedCompany,
|
||||||
|
CompanyName = "Stale Linked Name",
|
||||||
|
ContactName = "Older Tech",
|
||||||
|
Email = "older@linked.example",
|
||||||
|
Phone = "555-0101",
|
||||||
|
TradeSpecialties = "Plumbing",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 101 } }
|
||||||
|
},
|
||||||
|
new Vendor
|
||||||
|
{
|
||||||
|
Company = linkedCompany,
|
||||||
|
CompanyName = "Another Stale Name",
|
||||||
|
ContactName = "Newer Tech",
|
||||||
|
Email = "newer@linked.example",
|
||||||
|
Phone = "555-0102",
|
||||||
|
TradeSpecialties = "HVAC, plumbing",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 102 } }
|
||||||
|
},
|
||||||
|
new Vendor { CompanyName = " Legacy Co ", ContactName = "Legacy One", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = "legacy co", ContactName = "Legacy Two", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = " ", ContactName = "Unnamed One", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = null, ContactName = "Unnamed Two", IsActive = true, IsDeleted = false });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
var (items, totalCount) = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
|
||||||
|
|
||||||
|
totalCount.Should().Be(5);
|
||||||
|
var linked = items.Single(item => item.CompanyId == linkedCompany.Id);
|
||||||
|
linked.CompanyName.Should().Be("Linked Co");
|
||||||
|
linked.Email.Should().Be("dispatch@linked.example");
|
||||||
|
linked.ContactName.Should().Be("Newer Tech");
|
||||||
|
linked.Technicians.Should().HaveCount(2);
|
||||||
|
linked.TotalJobs.Should().Be(2);
|
||||||
|
linked.TradeSpecialties.Should().Be("HVAC, plumbing");
|
||||||
|
|
||||||
|
var canonicalSearch = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, search: "Linked Co", isActive: true);
|
||||||
|
canonicalSearch.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||||
|
|
||||||
|
var canonicalCompanyFilter = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, companies: new[] { "Linked Co" });
|
||||||
|
canonicalCompanyFilter.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
|
||||||
|
|
||||||
|
var legacy = items.Single(item =>
|
||||||
|
item.Technicians.Any(tech => tech.ContactName == "Legacy One"));
|
||||||
|
legacy.Technicians.Select(tech => tech.ContactName)
|
||||||
|
.Should().BeEquivalentTo("Legacy One", "Legacy Two");
|
||||||
|
items.Count(item => item.Technicians.Count == 1 &&
|
||||||
|
item.Technicians.Single().ContactName?.StartsWith("Unnamed") == true).Should().Be(2);
|
||||||
|
items.Single(item => item.CompanyId == companyOnly.Id).Technicians.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompanyDirectoryPagedAsync_FilterSelectsGroupAndSearchPromotesMatchingTechnician()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var company = new VendorCompany { Name = "Search Co", NormalizedName = "search co" };
|
||||||
|
context.VendorCompanies.Add(company);
|
||||||
|
var older = new Vendor
|
||||||
|
{
|
||||||
|
Company = company,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = "Alice Plumber",
|
||||||
|
TradeSpecialties = "Plumbing",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 201 } }
|
||||||
|
};
|
||||||
|
var newer = new Vendor
|
||||||
|
{
|
||||||
|
Company = company,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = "Zed HVAC",
|
||||||
|
TradeSpecialties = "HVAC",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 202 } }
|
||||||
|
};
|
||||||
|
var inactive = new Vendor
|
||||||
|
{
|
||||||
|
Company = company,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = "Inactive Tech",
|
||||||
|
TradeSpecialties = "Roofing",
|
||||||
|
IsActive = false,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = new List<Dispatch> { new() { WorkOrderId = 203 } }
|
||||||
|
};
|
||||||
|
context.Vendors.AddRange(older, newer, inactive);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
var search = await service.GetCompanyDirectoryPagedAsync(1, 10, search: "Alice", isActive: true);
|
||||||
|
var trade = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 10, isActive: true, trades: new[] { "Plumbing" });
|
||||||
|
|
||||||
|
search.TotalCount.Should().Be(1);
|
||||||
|
search.Items.Single().Id.Should().Be(older.Id);
|
||||||
|
search.Items.Single().ContactName.Should().Be("Alice Plumber");
|
||||||
|
search.Items.Single().Technicians.Should().HaveCount(2);
|
||||||
|
search.Items.Single().TotalJobs.Should().Be(2);
|
||||||
|
|
||||||
|
trade.TotalCount.Should().Be(1);
|
||||||
|
trade.Items.Single().Id.Should().Be(newer.Id);
|
||||||
|
trade.Items.Single().Technicians.Should().HaveCount(2);
|
||||||
|
trade.Items.Single().Technicians.Should().NotContain(tech => tech.Id == inactive.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCompanyDirectoryKeysQuery_TranslatesGroupingAndFacetFiltersToSqlServer()
|
||||||
|
{
|
||||||
|
using var context = NewSqlServerContext();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var sql = service.BuildCompanyDirectoryKeysQuery(
|
||||||
|
search: "alice",
|
||||||
|
trades: new[] { "Plumbing" },
|
||||||
|
locations: new[] { "Chicago, IL" },
|
||||||
|
jobBuckets: new[] { "under-50" }).ToQueryString();
|
||||||
|
|
||||||
|
sql.Should().Contain("GROUP BY");
|
||||||
|
sql.Should().Contain("LEFT JOIN");
|
||||||
|
sql.Should().Contain("TradeSpecialties");
|
||||||
|
sql.Should().Contain("TotalJobs");
|
||||||
|
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
||||||
|
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
|
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -152,12 +152,12 @@ public class VendorServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetVendorDirectoryPaged_ForwardsCancellationToken()
|
public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians()
|
||||||
{
|
{
|
||||||
var data = new Mock<IVendorDataService>();
|
var data = new Mock<IVendorDataService>();
|
||||||
using var cancellation = new CancellationTokenSource();
|
using var cancellation = new CancellationTokenSource();
|
||||||
var token = cancellation.Token;
|
var token = cancellation.Token;
|
||||||
data.Setup(x => x.GetDirectoryPagedAsync(
|
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
12,
|
12,
|
||||||
"clayton",
|
"clayton",
|
||||||
|
|
@ -167,16 +167,43 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
token))
|
token))
|
||||||
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 0));
|
.ReturnsAsync((new[]
|
||||||
|
{
|
||||||
|
new VendorCompanyGroup
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
CompanyId = 3,
|
||||||
|
CompanyName = "Gateway Plumbing",
|
||||||
|
ContactName = "Primary Tech",
|
||||||
|
Email = "company@gatewayplumbing.example",
|
||||||
|
TotalJobs = 4,
|
||||||
|
Technicians = new[]
|
||||||
|
{
|
||||||
|
new VendorCompanyGroupTechnician
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
ContactName = "Primary Tech",
|
||||||
|
Email = "primary@example.com",
|
||||||
|
TotalJobs = 4,
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.AsEnumerable().ToList(), 1));
|
||||||
|
|
||||||
await NewService(data).GetVendorDirectoryPagedAsync(
|
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
12,
|
12,
|
||||||
"clayton",
|
"clayton",
|
||||||
true,
|
true,
|
||||||
cancellationToken: token);
|
cancellationToken: token);
|
||||||
|
|
||||||
data.Verify(x => x.GetDirectoryPagedAsync(
|
var group = result.Items.Single();
|
||||||
|
group.Email.Should().Be("company@gatewayplumbing.example");
|
||||||
|
group.Technicians.Should().ContainSingle()
|
||||||
|
.Which.Email.Should().Be("primary@example.com");
|
||||||
|
|
||||||
|
data.Verify(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
1,
|
1,
|
||||||
12,
|
12,
|
||||||
"clayton",
|
"clayton",
|
||||||
|
|
@ -188,6 +215,78 @@ public class VendorServiceTests
|
||||||
token), Times.Once);
|
token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorCompanyDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||||
|
|
||||||
|
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(0, 500);
|
||||||
|
|
||||||
|
result.Page.Should().Be(1);
|
||||||
|
result.PageSize.Should().Be(100);
|
||||||
|
data.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorTechnicianDirectoryPaged_ForwardsRawPaginationAndCancellationToken()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var token = cancellation.Token;
|
||||||
|
data.Setup(x => x.GetDirectoryPagedAsync(
|
||||||
|
2,
|
||||||
|
500,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token))
|
||||||
|
.ReturnsAsync((new[]
|
||||||
|
{
|
||||||
|
new VendorDirectoryRow { VendorId = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
|
||||||
|
new VendorDirectoryRow { VendorId = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
|
||||||
|
}.AsEnumerable().ToList(), 2));
|
||||||
|
|
||||||
|
var result = await NewService(data).GetVendorTechnicianDirectoryPagedAsync(
|
||||||
|
2,
|
||||||
|
500,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
cancellationToken: token);
|
||||||
|
|
||||||
|
// Legacy pagination semantics: page and pageSize (including 500) pass through
|
||||||
|
// unclamped, and same-company technicians stay as distinct rows.
|
||||||
|
result.Page.Should().Be(2);
|
||||||
|
result.PageSize.Should().Be(500);
|
||||||
|
result.Items.Should().HaveCount(2);
|
||||||
|
result.Items.Select(v => v.Id).Should().BeEquivalentTo(new[] { 7, 9 });
|
||||||
|
result.Items.Should().OnlyContain(v => v.Technicians.Count == 0);
|
||||||
|
|
||||||
|
data.Verify(x => x.GetDirectoryPagedAsync(
|
||||||
|
2,
|
||||||
|
500,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -36,38 +36,46 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetLocationList")]
|
[HttpGet("GetLocationList")]
|
||||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken);
|
try
|
||||||
|
|
||||||
var data = pagedResult.Items.Select(l => new
|
|
||||||
{
|
{
|
||||||
Id = l.Id,
|
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||||
Name = l.Name,
|
|
||||||
Title = l.Title,
|
|
||||||
Address = l.Address1,
|
|
||||||
Address1 = l.Address1,
|
|
||||||
Address2 = l.Address2,
|
|
||||||
City = l.City,
|
|
||||||
State = l.State,
|
|
||||||
ZipCode = l.Zip,
|
|
||||||
Phone = l.PhoneNumber,
|
|
||||||
Contact = (string?)null,
|
|
||||||
ContactEmail = l.Email,
|
|
||||||
Status = l.Status,
|
|
||||||
AccountId = l.AccountId
|
|
||||||
});
|
|
||||||
|
|
||||||
var viewModel = new Pagination_DTO
|
var data = pagedResult.Items.Select(l => new
|
||||||
|
{
|
||||||
|
Id = l.Id,
|
||||||
|
Name = l.Name,
|
||||||
|
Title = l.Title,
|
||||||
|
Address = l.Address1,
|
||||||
|
Address1 = l.Address1,
|
||||||
|
Address2 = l.Address2,
|
||||||
|
City = l.City,
|
||||||
|
State = l.State,
|
||||||
|
ZipCode = l.Zip,
|
||||||
|
Phone = l.PhoneNumber,
|
||||||
|
Contact = (string?)null,
|
||||||
|
ContactEmail = l.Email,
|
||||||
|
Status = l.Status,
|
||||||
|
AccountId = l.AccountId
|
||||||
|
});
|
||||||
|
|
||||||
|
var viewModel = new Pagination_DTO
|
||||||
|
{
|
||||||
|
Data = data,
|
||||||
|
PageNumber = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = pagedResult.TotalCount,
|
||||||
|
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(viewModel);
|
||||||
|
}
|
||||||
|
catch (ValidationException vex)
|
||||||
{
|
{
|
||||||
Data = data,
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
PageNumber = page,
|
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||||
PageSize = pageSize,
|
}
|
||||||
TotalCount = pagedResult.TotalCount,
|
|
||||||
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize)
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(viewModel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-198 legacy per-technician directory contract. Existing consumers depend
|
||||||
|
// on one row per technician (no grouped Technicians) and on raw pagination
|
||||||
|
// semantics, including pageSize values above the grouped-directory bound.
|
||||||
[HttpGet("GetVendorList")]
|
[HttpGet("GetVendorList")]
|
||||||
public async Task<IActionResult> GetVendorList(
|
public async Task<IActionResult> GetVendorList(
|
||||||
string? search = "",
|
string? search = "",
|
||||||
|
|
@ -38,7 +41,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[FromQuery] string[]? jobBuckets = null,
|
[FromQuery] string[]? jobBuckets = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync(
|
var pagedResult = await _vendorService.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
search,
|
search,
|
||||||
|
|
@ -80,6 +83,75 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-281 company-grouped directory: one row per company with the full
|
||||||
|
// status-eligible technician roster nested under Technicians and bounded
|
||||||
|
// page sizes. Top-level Email is the company email; technician emails stay
|
||||||
|
// on the nested rows.
|
||||||
|
[HttpGet("GetVendorDirectoryList")]
|
||||||
|
public async Task<IActionResult> GetVendorDirectoryList(
|
||||||
|
string? search = "",
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 10,
|
||||||
|
bool? isActive = true,
|
||||||
|
[FromQuery] string[]? companies = null,
|
||||||
|
[FromQuery] string[]? trades = null,
|
||||||
|
[FromQuery] string[]? locations = null,
|
||||||
|
[FromQuery] string[]? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var data = pagedResult.Items.Select(v => new
|
||||||
|
{
|
||||||
|
v.Id,
|
||||||
|
CompanyName = v.CompanyName,
|
||||||
|
v.CompanyId,
|
||||||
|
v.ContactName,
|
||||||
|
v.Email,
|
||||||
|
v.Phone,
|
||||||
|
v.CompanyPhone,
|
||||||
|
v.PreferredContact,
|
||||||
|
v.Address,
|
||||||
|
v.City,
|
||||||
|
v.State,
|
||||||
|
Zip = v.Zipcode,
|
||||||
|
v.TradeSpecialties,
|
||||||
|
v.GoogleMapsUrl,
|
||||||
|
v.Notes,
|
||||||
|
v.IsActive,
|
||||||
|
v.TotalJobs,
|
||||||
|
Technicians = v.Technicians.Select(t => new
|
||||||
|
{
|
||||||
|
t.Id,
|
||||||
|
t.ContactName,
|
||||||
|
t.Email,
|
||||||
|
t.Phone,
|
||||||
|
t.PreferredContact,
|
||||||
|
t.TradeSpecialties,
|
||||||
|
t.IsActive,
|
||||||
|
t.TotalJobs
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new Pagination_DTO
|
||||||
|
{
|
||||||
|
Data = data,
|
||||||
|
PageNumber = pagedResult.Page,
|
||||||
|
PageSize = pagedResult.PageSize,
|
||||||
|
TotalCount = pagedResult.TotalCount,
|
||||||
|
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pagedResult.PageSize)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[HttpGet("GetById")]
|
[HttpGet("GetById")]
|
||||||
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
|
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
|
||||||
|
|
|
||||||
|
|
@ -29,21 +29,46 @@ public static class SentryObservability
|
||||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||||
.InformationalVersion;
|
.InformationalVersion;
|
||||||
|
|
||||||
return IsReleaseInformationalVersion(informationalVersion)
|
return ResolveCommitSha(informationalVersion);
|
||||||
? informationalVersion![ReleasePrefix.Length..]
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string ResolveRelease(string? informationalVersion) =>
|
public static string ResolveRelease(string? informationalVersion) =>
|
||||||
IsReleaseInformationalVersion(informationalVersion)
|
TryResolveCommitSha(informationalVersion, out var commitSha)
|
||||||
? informationalVersion!
|
? ReleasePrefix + commitSha
|
||||||
: LocalDevelopmentRelease;
|
: LocalDevelopmentRelease;
|
||||||
|
|
||||||
|
public static string? ResolveCommitSha(string? informationalVersion) =>
|
||||||
|
TryResolveCommitSha(informationalVersion, out var commitSha)
|
||||||
|
? commitSha
|
||||||
|
: null;
|
||||||
|
|
||||||
public static bool IsReleaseInformationalVersion(string? value) =>
|
public static bool IsReleaseInformationalVersion(string? value) =>
|
||||||
value is not null
|
TryResolveCommitSha(value, out _);
|
||||||
&& value.StartsWith(ReleasePrefix, StringComparison.Ordinal)
|
|
||||||
&& value.Length == ReleasePrefix.Length + CommitShaLength
|
private static bool TryResolveCommitSha(string? value, out string? commitSha)
|
||||||
&& value[ReleasePrefix.Length..].All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F');
|
{
|
||||||
|
commitSha = null;
|
||||||
|
|
||||||
|
if (value is null || !value.StartsWith(ReleasePrefix, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var body = value[ReleasePrefix.Length..];
|
||||||
|
var suffixSeparatorIndex = body.IndexOf('+');
|
||||||
|
var sha = suffixSeparatorIndex < 0 ? body : body[..suffixSeparatorIndex];
|
||||||
|
|
||||||
|
if (sha.Length != CommitShaLength || !sha.All(IsCommitShaHexDigit))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (suffixSeparatorIndex >= 0
|
||||||
|
&& !string.Equals(body[(suffixSeparatorIndex + 1)..], sha, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
commitSha = sha.ToLowerInvariant();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCommitShaHexDigit(char c) =>
|
||||||
|
c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||||
|
|
||||||
public static void ConfigureRequest(IHub hub, HttpContext context)
|
public static void ConfigureRequest(IHub hub, HttpContext context)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search,
|
string? search,
|
||||||
|
IReadOnlyCollection<string>? states,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var query = _context.Locations.AsNoTracking();
|
var query = _context.Locations.AsNoTracking();
|
||||||
|
|
@ -146,6 +147,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
l.City!.Contains(search));
|
l.City!.Contains(search));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (states is { Count: > 0 })
|
||||||
|
{
|
||||||
|
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
|
||||||
|
}
|
||||||
|
|
||||||
var totalCount = await query.CountAsync(cancellationToken);
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
var items = await query
|
var items = await query
|
||||||
.OrderBy(l => l.Name)
|
.OrderBy(l => l.Name)
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,188 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (items, totalCount);
|
return (items, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
public async Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Phase 1: select and paginate bounded company group keys in SQL.
|
||||||
|
var keyQuery = BuildCompanyDirectoryKeysQuery(
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
|
||||||
|
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var pageKeys = await keyQuery
|
||||||
|
.OrderByDescending(key => key.MaxVendorId ?? -1)
|
||||||
|
.ThenByDescending(key => key.CompanyId)
|
||||||
|
.ThenBy(key => key.NameKey)
|
||||||
|
.ThenBy(key => key.VendorKey)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (pageKeys.Count == 0)
|
||||||
|
return (Array.Empty<VendorCompanyGroup>(), totalCount);
|
||||||
|
|
||||||
|
// Phase 2: fetch the full status-eligible roster only for the page keys,
|
||||||
|
// without reapplying search/trade/location filters.
|
||||||
|
var rosterRows = await BuildCompanyDirectoryRosterQuery(isActive, pageKeys)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rosterByKey = rosterRows
|
||||||
|
.GroupBy(row => (CompanyId: row.CompanyId ?? 0, row.NameKey, row.VendorKey))
|
||||||
|
.ToDictionary(group => group.Key, group => group.OrderByDescending(row => row.VendorId).ToList());
|
||||||
|
|
||||||
|
var missingCompanyIds = pageKeys
|
||||||
|
.Where(key => key.CompanyId != 0)
|
||||||
|
.Where(key => !rosterByKey.ContainsKey((key.CompanyId, key.NameKey, key.VendorKey)))
|
||||||
|
.Select(key => key.CompanyId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var companiesById = missingCompanyIds.Count == 0
|
||||||
|
? new Dictionary<int, VendorCompany>()
|
||||||
|
: await _context.VendorCompanies
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => missingCompanyIds.Contains(c.Id))
|
||||||
|
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
||||||
|
|
||||||
|
// Assemble groups in the page key order so the response preserves the
|
||||||
|
// SQL-selected page order.
|
||||||
|
var groups = new List<VendorCompanyGroup>(pageKeys.Count);
|
||||||
|
foreach (var key in pageKeys)
|
||||||
|
{
|
||||||
|
if (rosterByKey.TryGetValue((key.CompanyId, key.NameKey, key.VendorKey), out var technicians))
|
||||||
|
{
|
||||||
|
groups.Add(BuildTechnicianGroup(technicians, search));
|
||||||
|
}
|
||||||
|
else if (companiesById.TryGetValue(key.CompanyId, out var company))
|
||||||
|
{
|
||||||
|
groups.Add(BuildCompanyOnlyGroup(company));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (groups, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorCompanyGroup BuildTechnicianGroup(
|
||||||
|
IReadOnlyList<VendorDirectoryRosterRow> techniciansNewestFirst,
|
||||||
|
string? search)
|
||||||
|
{
|
||||||
|
var primary = techniciansNewestFirst[0];
|
||||||
|
|
||||||
|
// Primary is the newest VendorId by default; a nonblank search that matches
|
||||||
|
// a non-primary technician's ContactName, email, Phone, or
|
||||||
|
// TradeSpecialties promotes the newest matching technician. Company-level
|
||||||
|
// matches (e.g. company name or location) never reorder.
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var normalizedSearch = search.ToLower();
|
||||||
|
primary = techniciansNewestFirst.FirstOrDefault(row =>
|
||||||
|
((row.ContactName ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||||
|
((row.Email ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||||
|
((row.Phone ?? "").ToLower().Contains(normalizedSearch)) ||
|
||||||
|
((row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch)))
|
||||||
|
?? techniciansNewestFirst[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var linked = primary.CompanyId != null;
|
||||||
|
var hasCompanyLocation = linked && (
|
||||||
|
!string.IsNullOrWhiteSpace(primary.CompanyCity) ||
|
||||||
|
!string.IsNullOrWhiteSpace(primary.CompanyState) ||
|
||||||
|
!string.IsNullOrWhiteSpace(primary.CompanyAddress));
|
||||||
|
|
||||||
|
return new VendorCompanyGroup
|
||||||
|
{
|
||||||
|
Id = primary.VendorId,
|
||||||
|
CompanyId = primary.CompanyId,
|
||||||
|
CompanyName = linked ? primary.CanonicalCompanyName : primary.CompanyName,
|
||||||
|
ContactName = primary.ContactName,
|
||||||
|
Email = linked ? primary.CompanyEmail : primary.Email,
|
||||||
|
Phone = primary.Phone,
|
||||||
|
CompanyPhone = linked && !string.IsNullOrWhiteSpace(primary.CompanyPhone)
|
||||||
|
? primary.CompanyPhone
|
||||||
|
: primary.VendorCompanyPhone,
|
||||||
|
PreferredContact = primary.PreferredContact,
|
||||||
|
Address = linked && !string.IsNullOrWhiteSpace(primary.CompanyAddress)
|
||||||
|
? primary.CompanyAddress
|
||||||
|
: primary.Address,
|
||||||
|
City = hasCompanyLocation ? primary.CompanyCity : primary.City,
|
||||||
|
State = hasCompanyLocation ? primary.CompanyState : primary.State,
|
||||||
|
Zip = primary.Zip,
|
||||||
|
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
||||||
|
GoogleMapsUrl = primary.GoogleMapsUrl,
|
||||||
|
Notes = linked ? primary.CompanyNotes : null,
|
||||||
|
IsActive = primary.IsActive,
|
||||||
|
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
||||||
|
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
||||||
|
{
|
||||||
|
Id = row.VendorId,
|
||||||
|
ContactName = row.ContactName,
|
||||||
|
Email = row.Email,
|
||||||
|
Phone = row.Phone,
|
||||||
|
PreferredContact = row.PreferredContact,
|
||||||
|
TradeSpecialties = row.TradeSpecialties,
|
||||||
|
IsActive = row.IsActive,
|
||||||
|
TotalJobs = row.TotalJobs
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct trade CSV tokens case-insensitively, preserving the first displayed
|
||||||
|
// spelling and order (technicians are listed newest VendorId first).
|
||||||
|
private static string? AggregateTradeTokens(IEnumerable<VendorDirectoryRosterRow> techniciansNewestFirst)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var tokens = new List<string>();
|
||||||
|
foreach (var row in techniciansNewestFirst)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(row.TradeSpecialties))
|
||||||
|
continue;
|
||||||
|
foreach (var token in row.TradeSpecialties.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (seen.Add(token))
|
||||||
|
tokens.Add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.Count == 0 ? null : string.Join(", ", tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorCompanyGroup BuildCompanyOnlyGroup(VendorCompany company) => new()
|
||||||
|
{
|
||||||
|
Id = null,
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CompanyName = company.Name,
|
||||||
|
ContactName = null,
|
||||||
|
Email = company.Email,
|
||||||
|
Phone = null,
|
||||||
|
CompanyPhone = company.CompanyPhone,
|
||||||
|
PreferredContact = null,
|
||||||
|
Address = company.Address,
|
||||||
|
City = company.City,
|
||||||
|
State = company.State,
|
||||||
|
Zip = company.Zip,
|
||||||
|
TradeSpecialties = null,
|
||||||
|
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||||
|
Notes = company.Notes,
|
||||||
|
IsActive = true,
|
||||||
|
TotalJobs = 0,
|
||||||
|
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
||||||
|
};
|
||||||
|
|
||||||
|
internal IQueryable<VendorCompanyGroupKeyRow> BuildCompanyDirectoryKeysQuery(
|
||||||
string? search = null,
|
string? search = null,
|
||||||
bool? isActive = true,
|
bool? isActive = true,
|
||||||
IReadOnlyCollection<string>? companies = null,
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
|
@ -176,52 +357,195 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null)
|
IReadOnlyCollection<string>? jobBuckets = null)
|
||||||
{
|
{
|
||||||
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
||||||
// those with no company). Notes come from the linked VendorCompany.
|
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
|
||||||
var perTechnician = _context.Vendors
|
|
||||||
|
var grouped = sourceRows
|
||||||
|
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
||||||
|
.Select(group => new VendorCompanyGroupKeyRow
|
||||||
|
{
|
||||||
|
CompanyId = group.Key.CompanyId,
|
||||||
|
NameKey = group.Key.NameKey,
|
||||||
|
VendorKey = group.Key.VendorKey,
|
||||||
|
MaxVendorId = group.Max(row => row.VendorId),
|
||||||
|
TotalJobs = group.Sum(row => row.TotalJobs),
|
||||||
|
SearchMatch = group.Max(row => row.SearchFlag),
|
||||||
|
CompanyMatch = group.Max(row => row.CompanyFlag),
|
||||||
|
TradeMatch = group.Max(row => row.TradeFlag),
|
||||||
|
LocationMatch = group.Max(row => row.LocationFlag)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each facet selects the group when any status-eligible row matches it
|
||||||
|
// (OR within a facet, AND across facets); job buckets apply to the summed
|
||||||
|
// per-technician TotalJobs of the whole company.
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
grouped = grouped.Where(key => key.SearchMatch == 1);
|
||||||
|
|
||||||
|
var selectedCompanies = companies?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedCompanies is { Length: > 0 })
|
||||||
|
grouped = grouped.Where(key => key.CompanyMatch == 1);
|
||||||
|
|
||||||
|
var selectedTrades = trades?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedTrades is { Length: > 0 })
|
||||||
|
grouped = grouped.Where(key => key.TradeMatch == 1);
|
||||||
|
|
||||||
|
var selectedLocations = locations?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedLocations is { Length: > 0 })
|
||||||
|
grouped = grouped.Where(key => key.LocationMatch == 1);
|
||||||
|
|
||||||
|
var selectedJobBuckets = jobBuckets?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (selectedJobBuckets is not { Length: > 0 })
|
||||||
|
return grouped;
|
||||||
|
|
||||||
|
var under50 = selectedJobBuckets.Contains("under-50");
|
||||||
|
var from50To99 = selectedJobBuckets.Contains("50-99");
|
||||||
|
var from100To149 = selectedJobBuckets.Contains("100-149");
|
||||||
|
var from150 = selectedJobBuckets.Contains("150-plus");
|
||||||
|
|
||||||
|
return grouped.Where(key =>
|
||||||
|
(under50 && key.TotalJobs < 50) ||
|
||||||
|
(from50To99 && key.TotalJobs >= 50 && key.TotalJobs <= 99) ||
|
||||||
|
(from100To149 && key.TotalJobs >= 100 && key.TotalJobs <= 149) ||
|
||||||
|
(from150 && key.TotalJobs >= 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>> BuildGroupSourceRowSelector(
|
||||||
|
string? search,
|
||||||
|
IReadOnlyCollection<string>? companies,
|
||||||
|
IReadOnlyCollection<string>? trades,
|
||||||
|
IReadOnlyCollection<string>? locations)
|
||||||
|
{
|
||||||
|
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
||||||
|
|
||||||
|
var selectedCompanies = companies?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var selectedTrades = trades?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var selectedLocations = locations?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
static Expression Flag(Expression? matchBody) =>
|
||||||
|
matchBody is null
|
||||||
|
? Expression.Constant(1)
|
||||||
|
: Expression.Condition(matchBody, Expression.Constant(1), Expression.Constant(0));
|
||||||
|
|
||||||
|
var searchBody = string.IsNullOrWhiteSpace(search)
|
||||||
|
? null
|
||||||
|
: BuildSearchMatchBody(row, search.ToLower());
|
||||||
|
var companyBody = selectedCompanies is { Length: > 0 }
|
||||||
|
? BuildCompanyNameMatchBody(row, selectedCompanies)
|
||||||
|
: null;
|
||||||
|
var tradeBody = selectedTrades is { Length: > 0 }
|
||||||
|
? BuildTradeMatchBody<VendorDirectoryRow>(row, selectedTrades)
|
||||||
|
: null;
|
||||||
|
var locationBody = selectedLocations is { Length: > 0 }
|
||||||
|
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var nameKey = Expression.Condition(
|
||||||
|
Expression.NotEqual(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||||
|
Expression.Constant(0)),
|
||||||
|
Expression.Constant(string.Empty),
|
||||||
|
LowerTrimmed(row, nameof(VendorDirectoryRow.CompanyName)));
|
||||||
|
|
||||||
|
var nameMissing = Expression.Equal(
|
||||||
|
Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||||
|
nameof(string.Trim),
|
||||||
|
Type.EmptyTypes),
|
||||||
|
Expression.Constant(string.Empty));
|
||||||
|
var vendorKey = Expression.Condition(
|
||||||
|
Expression.AndAlso(
|
||||||
|
Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId)),
|
||||||
|
Expression.Constant(0)),
|
||||||
|
nameMissing),
|
||||||
|
Expression.Coalesce(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.VendorId)),
|
||||||
|
Expression.Constant(0)),
|
||||||
|
Expression.Constant(0));
|
||||||
|
|
||||||
|
MemberBinding Bind(string target, Expression value) =>
|
||||||
|
Expression.Bind(typeof(VendorDirectoryGroupSourceRow).GetProperty(target)!, value);
|
||||||
|
|
||||||
|
return Expression.Lambda<Func<VendorDirectoryRow, VendorDirectoryGroupSourceRow>>(
|
||||||
|
Expression.MemberInit(
|
||||||
|
Expression.New(typeof(VendorDirectoryGroupSourceRow)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.VendorId),
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.VendorId))),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyId),
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.CompanyId))),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.NameKey), nameKey),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.VendorKey), vendorKey),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.TotalJobs),
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRow.TotalJobs))),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
|
||||||
|
row);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IQueryable<VendorDirectoryRosterRow> BuildCompanyDirectoryRosterQuery(
|
||||||
|
bool? isActive,
|
||||||
|
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||||
|
{
|
||||||
|
var vendors = _context.Vendors
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||||
|
|
||||||
if (isActive.HasValue)
|
if (isActive.HasValue)
|
||||||
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
vendors = vendors.Where(v => v.IsActive == isActive.Value);
|
||||||
|
|
||||||
var armA = perTechnician.Select(v => new VendorDirectoryRow
|
var rows = vendors.Select(v => new VendorDirectoryRosterRow
|
||||||
{
|
{
|
||||||
VendorId = v.Id,
|
VendorId = v.Id,
|
||||||
CompanyId = v.CompanyId ?? 0,
|
CompanyId = v.CompanyId,
|
||||||
CompanyName = v.CompanyName,
|
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||||
|
CanonicalCompanyName = v.Company != null ? v.Company.Name : null,
|
||||||
|
CompanyEmail = v.Company != null ? v.Company.Email : null,
|
||||||
|
CompanyPhone = v.Company != null ? v.Company.CompanyPhone : null,
|
||||||
|
CompanyAddress = v.Company != null ? v.Company.Address : null,
|
||||||
|
CompanyCity = v.Company != null ? v.Company.City : null,
|
||||||
|
CompanyState = v.Company != null ? v.Company.State : null,
|
||||||
|
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
||||||
ContactName = v.ContactName,
|
ContactName = v.ContactName,
|
||||||
Email = v.Email != null && v.Email.Trim() != ""
|
Email = v.Email,
|
||||||
? v.Email
|
|
||||||
: v.Company != null
|
|
||||||
? v.Company.Email
|
|
||||||
: v.Email,
|
|
||||||
Phone = v.Phone,
|
Phone = v.Phone,
|
||||||
CompanyPhone = v.Company != null &&
|
|
||||||
v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != ""
|
|
||||||
? v.Company.CompanyPhone
|
|
||||||
: v.CompanyPhone,
|
|
||||||
PreferredContact = v.PreferredContact,
|
PreferredContact = v.PreferredContact,
|
||||||
Address = v.Company != null &&
|
VendorCompanyPhone = v.CompanyPhone,
|
||||||
v.Company.Address != null && v.Company.Address.Trim() != ""
|
Address = v.Address,
|
||||||
? v.Company.Address
|
City = v.City,
|
||||||
: v.Address,
|
State = v.State,
|
||||||
City = v.Company != null &&
|
|
||||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
|
||||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
|
||||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
|
||||||
? v.Company.City
|
|
||||||
: v.City,
|
|
||||||
State = v.Company != null &&
|
|
||||||
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
|
||||||
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
|
||||||
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
|
||||||
? v.Company.State
|
|
||||||
: v.State,
|
|
||||||
Zip = v.Zip,
|
Zip = v.Zip,
|
||||||
TradeSpecialties = v.TradeSpecialties,
|
TradeSpecialties = v.TradeSpecialties,
|
||||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||||
Notes = v.Company != null ? v.Company.Notes : null,
|
|
||||||
IsActive = v.IsActive,
|
IsActive = v.IsActive,
|
||||||
TotalJobs = _context.Dispatches
|
TotalJobs = _context.Dispatches
|
||||||
.Where(dispatch =>
|
.Where(dispatch =>
|
||||||
|
|
@ -232,9 +556,156 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
_context.DispatchWorkOrders
|
_context.DispatchWorkOrders
|
||||||
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
||||||
.Select(link => link.WorkOrderId))
|
.Select(link => link.WorkOrderId))
|
||||||
.Count()
|
.Count(),
|
||||||
|
NameKey = v.CompanyId != null
|
||||||
|
? ""
|
||||||
|
: (v.CompanyName ?? "").Trim().ToLower(),
|
||||||
|
VendorKey = v.CompanyId == null && (v.CompanyName ?? "").Trim() == ""
|
||||||
|
? v.Id
|
||||||
|
: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return rows.Where(BuildRosterKeyPredicate(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression<Func<VendorDirectoryRosterRow, bool>> BuildRosterKeyPredicate(
|
||||||
|
IReadOnlyList<VendorCompanyGroupKeyRow> keys)
|
||||||
|
{
|
||||||
|
var row = Expression.Parameter(typeof(VendorDirectoryRosterRow), "row");
|
||||||
|
|
||||||
|
Expression? body = null;
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
Expression keyMatch;
|
||||||
|
if (key.CompanyId != 0)
|
||||||
|
{
|
||||||
|
keyMatch = Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||||
|
Expression.Constant(key.CompanyId, typeof(int?)));
|
||||||
|
}
|
||||||
|
else if (key.NameKey.Length > 0)
|
||||||
|
{
|
||||||
|
keyMatch = Expression.AndAlso(
|
||||||
|
Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.CompanyId)),
|
||||||
|
Expression.Constant(null, typeof(int?))),
|
||||||
|
Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.NameKey)),
|
||||||
|
Expression.Constant(key.NameKey)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
keyMatch = Expression.Equal(
|
||||||
|
Expression.Property(row, nameof(VendorDirectoryRosterRow.VendorId)),
|
||||||
|
Expression.Constant(key.VendorKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
body = OrElse(body, keyMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expression.Lambda<Func<VendorDirectoryRosterRow, bool>>(
|
||||||
|
body ?? Expression.Constant(false),
|
||||||
|
row);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null) =>
|
||||||
|
ApplyDirectoryRowFilters(
|
||||||
|
BuildDirectoryUnionBaseQuery(isActive),
|
||||||
|
search,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
|
||||||
|
private IQueryable<VendorDirectoryRow> BuildDirectoryUnionBaseQuery(bool? isActive = true)
|
||||||
|
{
|
||||||
|
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
||||||
|
// those with no company). Notes come from the linked VendorCompany.
|
||||||
|
var perTechnician = _context.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||||
|
|
||||||
|
if (isActive.HasValue)
|
||||||
|
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
||||||
|
|
||||||
|
// Pre-aggregate distinct work-order assignments per technician before
|
||||||
|
// grouping companies. This keeps the outer company SUM over a plain
|
||||||
|
// joined scalar instead of SUM(COUNT(correlated subquery)), which SQL
|
||||||
|
// Server rejects at execution time.
|
||||||
|
var directWorkOrders = _context.Dispatches
|
||||||
|
.Where(dispatch => dispatch.WorkOrderId.HasValue)
|
||||||
|
.Select(dispatch => new
|
||||||
|
{
|
||||||
|
dispatch.VendorId,
|
||||||
|
WorkOrderId = dispatch.WorkOrderId!.Value
|
||||||
|
});
|
||||||
|
var linkedWorkOrders = _context.DispatchWorkOrders
|
||||||
|
.Where(link => link.Dispatch != null)
|
||||||
|
.Select(link => new
|
||||||
|
{
|
||||||
|
VendorId = link.Dispatch!.VendorId,
|
||||||
|
link.WorkOrderId
|
||||||
|
});
|
||||||
|
var jobCounts = directWorkOrders
|
||||||
|
.Union(linkedWorkOrders)
|
||||||
|
.GroupBy(assignment => assignment.VendorId)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
VendorId = group.Key,
|
||||||
|
TotalJobs = group.Count()
|
||||||
|
});
|
||||||
|
|
||||||
|
var armA =
|
||||||
|
from v in perTechnician
|
||||||
|
join jobCount in jobCounts on v.Id equals jobCount.VendorId into vendorJobCounts
|
||||||
|
from jobCount in vendorJobCounts.DefaultIfEmpty()
|
||||||
|
select new VendorDirectoryRow
|
||||||
|
{
|
||||||
|
VendorId = v.Id,
|
||||||
|
CompanyId = v.CompanyId ?? 0,
|
||||||
|
CompanyName = v.Company != null ? v.Company.Name : v.CompanyName,
|
||||||
|
ContactName = v.ContactName,
|
||||||
|
Email = v.Email != null && v.Email.Trim() != ""
|
||||||
|
? v.Email
|
||||||
|
: v.Company != null
|
||||||
|
? v.Company.Email
|
||||||
|
: v.Email,
|
||||||
|
Phone = v.Phone,
|
||||||
|
CompanyPhone = v.Company != null &&
|
||||||
|
v.Company.CompanyPhone != null && v.Company.CompanyPhone.Trim() != ""
|
||||||
|
? v.Company.CompanyPhone
|
||||||
|
: v.CompanyPhone,
|
||||||
|
PreferredContact = v.PreferredContact,
|
||||||
|
Address = v.Company != null &&
|
||||||
|
v.Company.Address != null && v.Company.Address.Trim() != ""
|
||||||
|
? v.Company.Address
|
||||||
|
: v.Address,
|
||||||
|
City = v.Company != null &&
|
||||||
|
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||||
|
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||||
|
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||||
|
? v.Company.City
|
||||||
|
: v.City,
|
||||||
|
State = v.Company != null &&
|
||||||
|
((v.Company.City != null && v.Company.City.Trim() != "") ||
|
||||||
|
(v.Company.State != null && v.Company.State.Trim() != "") ||
|
||||||
|
(v.Company.Address != null && v.Company.Address.Trim() != ""))
|
||||||
|
? v.Company.State
|
||||||
|
: v.State,
|
||||||
|
Zip = v.Zip,
|
||||||
|
TradeSpecialties = v.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||||
|
Notes = v.Company != null ? v.Company.Notes : null,
|
||||||
|
IsActive = v.IsActive,
|
||||||
|
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
// Arm B: company-only rows for companies with zero non-deleted technicians.
|
// Arm B: company-only rows for companies with zero non-deleted technicians.
|
||||||
// These carry a null VendorId and a populated CompanyId. A company has no
|
// These carry a null VendorId and a populated CompanyId. A company has no
|
||||||
// active/inactive flag of its own, so it is shown unless the caller explicitly
|
// active/inactive flag of its own, so it is shown unless the caller explicitly
|
||||||
|
|
@ -272,15 +743,70 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
union = armA.Concat(armB);
|
union = armA.Concat(armB);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApplyDirectoryRowFilters(
|
return union;
|
||||||
union,
|
|
||||||
search,
|
|
||||||
companies,
|
|
||||||
trades,
|
|
||||||
locations,
|
|
||||||
jobBuckets);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same matching rules the directory has always applied to a projected row:
|
||||||
|
// company name, contact name, email, trades, canonical city/state, the
|
||||||
|
// canonical address only when city/state are both absent, and zip.
|
||||||
|
private static Expression BuildSearchMatchBody(ParameterExpression row, string normalizedSearch)
|
||||||
|
{
|
||||||
|
Expression ContainsLower(string propertyName)
|
||||||
|
{
|
||||||
|
var lowered = Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, propertyName),
|
||||||
|
nameof(string.ToLower),
|
||||||
|
Type.EmptyTypes);
|
||||||
|
return Contains(lowered, Expression.Constant(normalizedSearch));
|
||||||
|
}
|
||||||
|
|
||||||
|
var cityBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.City)));
|
||||||
|
var stateBlank = IsBlank(Expression.Property(row, nameof(VendorDirectoryRow.State)));
|
||||||
|
|
||||||
|
Expression? body = null;
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.CompanyName)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.ContactName)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Email)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.TradeSpecialties)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.City)));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.State)));
|
||||||
|
body = OrElse(body, Expression.AndAlso(
|
||||||
|
Expression.AndAlso(cityBlank, stateBlank),
|
||||||
|
ContainsLower(nameof(VendorDirectoryRow.Address))));
|
||||||
|
body = OrElse(body, ContainsLower(nameof(VendorDirectoryRow.Zip)));
|
||||||
|
return body!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression BuildCompanyNameMatchBody(
|
||||||
|
ParameterExpression row,
|
||||||
|
IReadOnlyCollection<string> selectedCompanies)
|
||||||
|
{
|
||||||
|
var lowered = Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, nameof(VendorDirectoryRow.CompanyName)),
|
||||||
|
nameof(string.ToLower),
|
||||||
|
Type.EmptyTypes);
|
||||||
|
return Expression.Call(
|
||||||
|
typeof(Enumerable),
|
||||||
|
nameof(Enumerable.Contains),
|
||||||
|
new[] { typeof(string) },
|
||||||
|
Expression.Constant(selectedCompanies),
|
||||||
|
lowered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression CoalescedOrEmpty(ParameterExpression row, string propertyName) =>
|
||||||
|
Expression.Coalesce(
|
||||||
|
Expression.Property(row, propertyName),
|
||||||
|
Expression.Constant(string.Empty));
|
||||||
|
|
||||||
|
private static Expression LowerTrimmed(ParameterExpression row, string propertyName) =>
|
||||||
|
Expression.Call(
|
||||||
|
Expression.Call(
|
||||||
|
CoalescedOrEmpty(row, propertyName),
|
||||||
|
nameof(string.Trim),
|
||||||
|
Type.EmptyTypes),
|
||||||
|
nameof(string.ToLower),
|
||||||
|
Type.EmptyTypes);
|
||||||
|
|
||||||
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
||||||
IQueryable<VendorDirectoryRow> query,
|
IQueryable<VendorDirectoryRow> query,
|
||||||
string? search,
|
string? search,
|
||||||
|
|
@ -463,6 +989,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
IReadOnlyCollection<string> selectedTrades)
|
IReadOnlyCollection<string> selectedTrades)
|
||||||
{
|
{
|
||||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||||
|
return Expression.Lambda<Func<T, bool>>(
|
||||||
|
BuildTradeMatchBody<T>(vendor, selectedTrades),
|
||||||
|
vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression BuildTradeMatchBody<T>(
|
||||||
|
ParameterExpression vendor,
|
||||||
|
IReadOnlyCollection<string> selectedTrades)
|
||||||
|
{
|
||||||
var specialties = Expression.Coalesce(
|
var specialties = Expression.Coalesce(
|
||||||
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||||
Expression.Constant(string.Empty));
|
Expression.Constant(string.Empty));
|
||||||
|
|
@ -490,13 +1025,22 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
return body ?? Expression.Constant(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
||||||
IReadOnlyCollection<string> selectedLocations)
|
IReadOnlyCollection<string> selectedLocations)
|
||||||
{
|
{
|
||||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||||
|
return Expression.Lambda<Func<T, bool>>(
|
||||||
|
BuildLocationMatchBody<T>(vendor, selectedLocations),
|
||||||
|
vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression BuildLocationMatchBody<T>(
|
||||||
|
ParameterExpression vendor,
|
||||||
|
IReadOnlyCollection<string> selectedLocations)
|
||||||
|
{
|
||||||
var label = BuildLocationLabelBody(vendor);
|
var label = BuildLocationLabelBody(vendor);
|
||||||
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
||||||
|
|
||||||
|
|
@ -504,7 +1048,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
foreach (var location in selectedLocations)
|
foreach (var location in selectedLocations)
|
||||||
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
||||||
|
|
||||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
return body ?? Expression.Constant(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
Task<int> CountAsync();
|
Task<int> CountAsync();
|
||||||
|
|
||||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
|
||||||
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||||
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
|
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null);
|
IReadOnlyCollection<string>? jobBuckets = null);
|
||||||
|
|
||||||
// SH-198 vendor directory: per-technician rows UNION company-only rows
|
// Compatibility path for existing per-technician consumers (GetVendorList)
|
||||||
// (companies with zero non-deleted technicians). Company-only rows carry a
|
// and behavior tests. GetVendorDirectoryList uses the company-grouped path
|
||||||
// null VendorId and a populated CompanyId. Notes come from VendorCompany.Notes.
|
// below.
|
||||||
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|
@ -48,6 +48,25 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// SH-281 company directory: linked vendors group by real CompanyId; unlinked
|
||||||
|
// legacy vendors group by trimmed case-insensitive CompanyName (missing names
|
||||||
|
// isolated by vendor identity); company-only groups remain. Facet filters
|
||||||
|
// select a group when any status-eligible row matches; job buckets apply to
|
||||||
|
// the summed per-technician TotalJobs. Bounded group keys are selected and
|
||||||
|
// paginated in SQL, then the full status-eligible roster is fetched only for
|
||||||
|
// those keys (search/trade/location are not reapplied). TotalCount counts
|
||||||
|
// company groups.
|
||||||
|
Task<(IReadOnlyList<VendorCompanyGroup> Items, int TotalCount)> GetCompanyDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
Task<Vendor> AddAsync(Vendor vendor);
|
Task<Vendor> AddAsync(Vendor vendor);
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,112 @@ namespace SeaHaven.DataServices.Models
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
||||||
|
// a missing name by vendor identity) plus one 0/1 match flag per facet filter so a
|
||||||
|
// group can be selected when any status-eligible row matches that facet.
|
||||||
|
internal sealed class VendorDirectoryGroupSourceRow
|
||||||
|
{
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public string NameKey { get; set; } = string.Empty;
|
||||||
|
public int VendorKey { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public int SearchFlag { get; set; }
|
||||||
|
public int CompanyFlag { get; set; }
|
||||||
|
public int TradeFlag { get; set; }
|
||||||
|
public int LocationFlag { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
||||||
|
internal sealed class VendorCompanyGroupKeyRow
|
||||||
|
{
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public string NameKey { get; set; } = string.Empty;
|
||||||
|
public int VendorKey { get; set; }
|
||||||
|
public int? MaxVendorId { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public int SearchMatch { get; set; }
|
||||||
|
public int CompanyMatch { get; set; }
|
||||||
|
public int TradeMatch { get; set; }
|
||||||
|
public int LocationMatch { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
||||||
|
// technician fields and the linked VendorCompany canonical fields, so group
|
||||||
|
// assembly can apply the existing company-canonical projections in memory without
|
||||||
|
// reapplying search/trade/location filters.
|
||||||
|
internal sealed class VendorDirectoryRosterRow
|
||||||
|
{
|
||||||
|
public int VendorId { get; set; }
|
||||||
|
public int? CompanyId { get; set; }
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? CanonicalCompanyName { get; set; }
|
||||||
|
public string? CompanyEmail { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? CompanyAddress { get; set; }
|
||||||
|
public string? CompanyCity { get; set; }
|
||||||
|
public string? CompanyState { get; set; }
|
||||||
|
public string? CompanyNotes { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? VendorCompanyPhone { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public string NameKey { get; set; } = string.Empty;
|
||||||
|
public int VendorKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: one technician inside a company group.
|
||||||
|
public sealed class VendorCompanyGroupTechnician
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281 company directory group: linked vendors collapse by real CompanyId;
|
||||||
|
// unlinked legacy vendors group by trimmed case-insensitive CompanyName (vendors
|
||||||
|
// with a missing name stay isolated by vendor identity); companies with zero
|
||||||
|
// non-deleted technicians remain as company-only groups. Top-level technician
|
||||||
|
// fields come from the primary technician (newest VendorId, promoted when a
|
||||||
|
// nonblank search matches a non-primary technician); company fields are
|
||||||
|
// company-canonical as the existing projections intend; TotalJobs is the sum of
|
||||||
|
// every status-eligible technician's distinct work-order count.
|
||||||
|
public sealed class VendorCompanyGroup
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public int? CompanyId { get; set; }
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,13 @@ namespace SeaHaven.Services.DTOs
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-198 vendor directory row. Id is the nullable vendor id (null for company-only
|
// Shared directory row. On the SH-198 legacy per-technician path (GetVendorList)
|
||||||
// rows that have zero technicians); CompanyId is always populated.
|
// Id is the technician's own vendor id and Technicians stays empty — the legacy
|
||||||
|
// response shape does not project it. On the SH-281 company-grouped path
|
||||||
|
// (GetVendorDirectoryList) Id is the primary technician's vendor id (null for
|
||||||
|
// company-only groups that have zero technicians); CompanyId is populated for
|
||||||
|
// linked and company-only groups. Technicians carries the full status-eligible
|
||||||
|
// roster of the company group.
|
||||||
public class VendorDirectoryItemDTO
|
public class VendorDirectoryItemDTO
|
||||||
{
|
{
|
||||||
public int? Id { get; set; }
|
public int? Id { get; set; }
|
||||||
|
|
@ -42,6 +47,20 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Notes { get; set; }
|
public string? Notes { 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-281: one technician inside a company group; Email/Phone stay technician-owned.
|
||||||
|
public class VendorDirectoryTechnicianDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateVendorDTO
|
public class CreateVendorDTO
|
||||||
|
|
|
||||||
68
SeaHaven.Services/Helpers/UsStateCodes.cs
Normal file
68
SeaHaven.Services/Helpers/UsStateCodes.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class UsStateCodes
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> Names = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["AL"] = "ALABAMA",
|
||||||
|
["AK"] = "ALASKA",
|
||||||
|
["AZ"] = "ARIZONA",
|
||||||
|
["AR"] = "ARKANSAS",
|
||||||
|
["CA"] = "CALIFORNIA",
|
||||||
|
["CO"] = "COLORADO",
|
||||||
|
["CT"] = "CONNECTICUT",
|
||||||
|
["DE"] = "DELAWARE",
|
||||||
|
["FL"] = "FLORIDA",
|
||||||
|
["GA"] = "GEORGIA",
|
||||||
|
["HI"] = "HAWAII",
|
||||||
|
["ID"] = "IDAHO",
|
||||||
|
["IL"] = "ILLINOIS",
|
||||||
|
["IN"] = "INDIANA",
|
||||||
|
["IA"] = "IOWA",
|
||||||
|
["KS"] = "KANSAS",
|
||||||
|
["KY"] = "KENTUCKY",
|
||||||
|
["LA"] = "LOUISIANA",
|
||||||
|
["ME"] = "MAINE",
|
||||||
|
["MD"] = "MARYLAND",
|
||||||
|
["MA"] = "MASSACHUSETTS",
|
||||||
|
["MI"] = "MICHIGAN",
|
||||||
|
["MN"] = "MINNESOTA",
|
||||||
|
["MS"] = "MISSISSIPPI",
|
||||||
|
["MO"] = "MISSOURI",
|
||||||
|
["MT"] = "MONTANA",
|
||||||
|
["NE"] = "NEBRASKA",
|
||||||
|
["NV"] = "NEVADA",
|
||||||
|
["NH"] = "NEW HAMPSHIRE",
|
||||||
|
["NJ"] = "NEW JERSEY",
|
||||||
|
["NM"] = "NEW MEXICO",
|
||||||
|
["NY"] = "NEW YORK",
|
||||||
|
["NC"] = "NORTH CAROLINA",
|
||||||
|
["ND"] = "NORTH DAKOTA",
|
||||||
|
["OH"] = "OHIO",
|
||||||
|
["OK"] = "OKLAHOMA",
|
||||||
|
["OR"] = "OREGON",
|
||||||
|
["PA"] = "PENNSYLVANIA",
|
||||||
|
["RI"] = "RHODE ISLAND",
|
||||||
|
["SC"] = "SOUTH CAROLINA",
|
||||||
|
["SD"] = "SOUTH DAKOTA",
|
||||||
|
["TN"] = "TENNESSEE",
|
||||||
|
["TX"] = "TEXAS",
|
||||||
|
["UT"] = "UTAH",
|
||||||
|
["VT"] = "VERMONT",
|
||||||
|
["VA"] = "VIRGINIA",
|
||||||
|
["WA"] = "WASHINGTON",
|
||||||
|
["WV"] = "WEST VIRGINIA",
|
||||||
|
["WI"] = "WISCONSIN",
|
||||||
|
["WY"] = "WYOMING"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IReadOnlySet<string> All { get; } = new HashSet<string>(Names.Keys, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public static IReadOnlyCollection<string> ExpandStorageValues(IEnumerable<string> codes)
|
||||||
|
{
|
||||||
|
return codes
|
||||||
|
.SelectMany(code => new[] { code, Names[code] })
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -165,9 +165,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search,
|
string? search,
|
||||||
CancellationToken cancellationToken)
|
string? states = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, cancellationToken);
|
var stateFilter = NormalizeStateFilter(states);
|
||||||
|
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
|
||||||
|
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
|
||||||
return new PagedResult<LocationDTO>
|
return new PagedResult<LocationDTO>
|
||||||
{
|
{
|
||||||
Items = items.Select(MapToDTO).ToList(),
|
Items = items.Select(MapToDTO).ToList(),
|
||||||
|
|
@ -177,6 +180,32 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyCollection<string>? NormalizeStateFilter(string? states)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(states))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = states
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(code => code.ToUpperInvariant())
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (normalized.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var invalid = normalized.Where(code => !UsStateCodes.All.Contains(code)).ToList();
|
||||||
|
if (invalid.Count > 0)
|
||||||
|
{
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure("states", $"states must be valid US postal abbreviations: {string.Join(", ", invalid)}.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken)
|
public async Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken);
|
var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
public class VendorService : IVendorService
|
public class VendorService : IVendorService
|
||||||
{
|
{
|
||||||
|
private const int MaxDirectoryPageSize = 100;
|
||||||
private static readonly string[] TerminalLegacyStatuses =
|
private static readonly string[] TerminalLegacyStatuses =
|
||||||
{ "completed", "cancelled", "canceled" };
|
{ "completed", "cancelled", "canceled" };
|
||||||
|
|
||||||
|
|
@ -94,7 +95,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
// SH-198 legacy per-technician directory contract (GetVendorList): raw
|
||||||
|
// pagination semantics are part of the compatibility surface, so pageSize
|
||||||
|
// is forwarded unclamped.
|
||||||
|
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search = null,
|
string? search = null,
|
||||||
|
|
@ -146,6 +150,74 @@ namespace SeaHaven.Services.Implementation
|
||||||
TotalJobs = row.TotalJobs
|
TotalJobs = row.TotalJobs
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SH-281 company-grouped directory (GetVendorDirectoryList).
|
||||||
|
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedPage = Math.Max(1, page);
|
||||||
|
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
||||||
|
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
||||||
|
normalizedPage,
|
||||||
|
normalizedPageSize,
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new PagedResult<VendorDirectoryItemDTO>
|
||||||
|
{
|
||||||
|
Items = items.Select(MapToCompanyGroup).ToList(),
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = normalizedPage,
|
||||||
|
PageSize = normalizedPageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
||||||
|
{
|
||||||
|
Id = group.Id,
|
||||||
|
CompanyId = group.CompanyId,
|
||||||
|
CompanyName = group.CompanyName,
|
||||||
|
ContactName = group.ContactName,
|
||||||
|
Email = group.Email,
|
||||||
|
Phone = group.Phone,
|
||||||
|
CompanyPhone = group.CompanyPhone,
|
||||||
|
PreferredContact = group.PreferredContact,
|
||||||
|
Address = group.Address,
|
||||||
|
City = group.City,
|
||||||
|
State = group.State,
|
||||||
|
Zipcode = group.Zip,
|
||||||
|
TradeSpecialties = group.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = group.GoogleMapsUrl,
|
||||||
|
Notes = group.Notes,
|
||||||
|
IsActive = group.IsActive,
|
||||||
|
TotalJobs = group.TotalJobs,
|
||||||
|
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static VendorDirectoryTechnicianDTO MapToTechnician(VendorCompanyGroupTechnician technician) => new()
|
||||||
|
{
|
||||||
|
Id = technician.Id,
|
||||||
|
ContactName = technician.ContactName,
|
||||||
|
Email = technician.Email,
|
||||||
|
Phone = technician.Phone,
|
||||||
|
PreferredContact = technician.PreferredContact,
|
||||||
|
TradeSpecialties = technician.TradeSpecialties,
|
||||||
|
IsActive = technician.IsActive,
|
||||||
|
TotalJobs = technician.TotalJobs
|
||||||
|
};
|
||||||
|
|
||||||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||||||
{
|
{
|
||||||
NormalizePhoneFields(dto);
|
NormalizePhoneFields(dto);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<bool> LocationExistsAsync(int id);
|
Task<bool> LocationExistsAsync(int id);
|
||||||
Task<int> GetTotalLocationCountAsync();
|
Task<int> GetTotalLocationCountAsync();
|
||||||
|
|
||||||
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default);
|
||||||
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
||||||
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||||
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,22 @@ namespace SeaHaven.Services.Interfaces
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null);
|
IReadOnlyCollection<string>? jobBuckets = null);
|
||||||
|
|
||||||
// SH-198 vendor directory: per-technician rows UNION company-only rows.
|
// SH-198 legacy vendor directory contract backing GetVendorList: one row per
|
||||||
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
// technician (per-technician rows UNION company-only rows), raw pagination.
|
||||||
|
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// SH-281 company directory backing GetVendorDirectoryList: company-grouped
|
||||||
|
// rows with nested technicians and bounded page sizes.
|
||||||
|
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search = null,
|
string? search = null,
|
||||||
|
|
|
||||||
|
|
@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests
|
||||||
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
||||||
public Task<int> CountAsync() => _inner.CountAsync();
|
public Task<int> CountAsync() => _inner.CountAsync();
|
||||||
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||||
int page, int pageSize, string? search, CancellationToken cancellationToken)
|
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken)
|
||||||
=> _inner.GetListPagedAsync(page, pageSize, search, cancellationToken);
|
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||||
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
||||||
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,14 @@ route values, query strings, or identity claims.
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
1. **Release identity.** `SentryObservability.ResolveRelease(Assembly)` accepts an
|
1. **Release identity.** `SentryObservability.ResolveRelease(Assembly)` accepts an
|
||||||
informational version shaped `shoc-backend@<40 hex commit sha>` and otherwise
|
informational version shaped `shoc-backend@<40 hex commit sha>`, tolerates the
|
||||||
resolves to `local-development`. `scripts/package-elastic-beanstalk.sh` resolves
|
.NET SDK's matching `+<same sha>` suffix for compatibility, canonicalizes the
|
||||||
|
result to lowercase, and otherwise resolves to `local-development`.
|
||||||
|
`scripts/package-elastic-beanstalk.sh` resolves
|
||||||
the commit from `APP_COMMIT_SHA` → `GITHUB_SHA` → `git rev-parse HEAD`, fails
|
the commit from `APP_COMMIT_SHA` → `GITHUB_SHA` → `git rev-parse HEAD`, fails
|
||||||
when `CI=true` cannot produce a valid 40-hex sha, and publishes with
|
when `CI=true` cannot produce a valid 40-hex sha, and publishes with
|
||||||
`-p:SourceRevisionId`, `-p:InformationalVersion=shoc-backend@<sha>`,
|
`-p:SourceRevisionId`, `-p:InformationalVersion=shoc-backend@<sha>`,
|
||||||
|
`-p:IncludeSourceRevisionInInformationalVersion=false`,
|
||||||
`-p:DebugType=portable`, `-p:DebugSymbols=true`.
|
`-p:DebugType=portable`, `-p:DebugSymbols=true`.
|
||||||
2. **Default tags.** `service=shoc-backend` and `app.commit=<sha | local-development>`
|
2. **Default tags.** `service=shoc-backend` and `app.commit=<sha | local-development>`
|
||||||
on `SentryOptions.DefaultTags`. All events use the same commit-addressed
|
on `SentryOptions.DefaultTags`. All events use the same commit-addressed
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ log "publish $API_PROJECT (Release, self-contained, $RUNTIME)"
|
||||||
-p:UseAppHost=true \
|
-p:UseAppHost=true \
|
||||||
-p:SourceRevisionId="$COMMIT_SHA" \
|
-p:SourceRevisionId="$COMMIT_SHA" \
|
||||||
-p:InformationalVersion="shoc-backend@$COMMIT_SHA" \
|
-p:InformationalVersion="shoc-backend@$COMMIT_SHA" \
|
||||||
|
-p:IncludeSourceRevisionInInformationalVersion=false \
|
||||||
-p:DebugType=portable \
|
-p:DebugType=portable \
|
||||||
-p:DebugSymbols=true
|
-p:DebugSymbols=true
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue